diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c7a4fcf --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2016 Dynamic Network Services Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md index 0985e90..ff406a6 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,95 @@ # qstring -Golang HTTP Query String Marshaler/Unmarshaler +The package provides an easy way to marshal and unmarshal query string data to +and from structs. + +# Installation +```bash +$ go get github.com/dyninc/qstring +``` + +# Example + +## Literal + +```go +package main + +import ( + "net/http" + + "github.com/dyninc/qstring" +) + +// Query is the request query struct. +type Query struct { + Names []string + Limit int + Page int +} + +func handler(w http.ResponseWriter, req *http.Request) { + query := &Query{} + err := qstring.Unmarshal(req.Url.Query(), query) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + } + + // ... run conditional logic based on provided query parameters +} +``` + +## Nested +In the same spirit as other Unmarshalling libraries, `qstring` allows you to +Marshal/Unmarshal nested structs + +```go +package main + +import ( + "net/http" + + "github.com/dyninc/qstring" +) + +// PagingParams represents common pagination information for query strings +type PagingParams struct { + Page int + Limit int +} + +// Req is the request query struct. +type Query struct { + Names []string + PageInfo PagingParams +} +``` + +## Complex Structures +Again, in the spirit of other Unmarshalling libraries, `qstring` allows for some +more complex types, such as pointers and time.Time fields. *Note: All Timestamps +are assumed to be in RFC3339 format*. + +```go +package main + +import ( + "net/http" + "time" + + "github.com/dyninc/qstring" +) + +// PagingParams represents common pagination information for query strings +type PagingParams struct { + Page int + Limit int +} + +// Req is the request query struct. +type Query struct { + Names []string + PageInfo *PagingParams + Created time.TIme + Modified time.Time +} +``` diff --git a/bench_test.go b/bench_test.go new file mode 100644 index 0000000..b2c9cf3 --- /dev/null +++ b/bench_test.go @@ -0,0 +1,52 @@ +package qstring + +import ( + "net/url" + "testing" +) + +// Straight benchmark literal. +func BenchmarkUnmarshall(b *testing.B) { + query := url.Values{ + "limit": []string{"10"}, + "page": []string{"1"}, + "fields": []string{"a", "b", "c"}, + } + type QueryStruct struct { + Fields []string + Limit int + Page int + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + data := &QueryStruct{} + err := Unmarshal(query, data) + if err != nil { + b.Fatal(err) + } + } +} + +// Parallel benchmark literal. +func BenchmarkRawPLiteral(b *testing.B) { + query := url.Values{ + "limit": []string{"10"}, + "page": []string{"1"}, + "fields": []string{"a", "b", "c"}, + } + type QueryStruct struct { + Fields []string + Limit int + Page int + } + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + data := &QueryStruct{} + err := Unmarshal(query, data) + if err != nil { + b.Fatal(err) + } + } + }) +} diff --git a/coverage.html b/coverage.html new file mode 100644 index 0000000..36fe38b --- /dev/null +++ b/coverage.html @@ -0,0 +1,443 @@ + + + + + + + + +
+ +
+ not tracked + + not covered + covered + +
+
+
+ + + + + + + +
+ + + diff --git a/coverage.out b/coverage.out new file mode 100644 index 0000000..6e79be0 --- /dev/null +++ b/coverage.out @@ -0,0 +1,97 @@ +mode: set +github.corp.dyndns.com/Zephyr/coredns/qstring/decode.go:19.54,23.2 3 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/decode.go:31.47,32.19 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/decode.go:36.2,36.34 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/decode.go:39.2,39.58 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/decode.go:32.19,34.3 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/decode.go:36.34,38.3 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/decode.go:46.50,49.2 2 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/decode.go:51.50,53.44 2 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/decode.go:57.2,57.20 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/decode.go:53.44,55.3 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/decode.go:60.50,63.39 3 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/decode.go:93.2,93.12 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/decode.go:63.39,68.20 4 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/decode.go:75.3,75.44 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/decode.go:80.3,80.39 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/decode.go:68.20,71.4 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/decode.go:75.44,76.12 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/decode.go:80.39,81.40 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/decode.go:82.4,83.39 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/decode.go:84.4,85.37 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/decode.go:87.4,87.52 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/decode.go:87.52,88.27 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/decode.go:88.27,90.5 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/decode.go:98.88,102.16 3 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/decode.go:138.2,138.12 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/decode.go:103.2,104.25 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/decode.go:105.2,107.17 2 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/decode.go:110.2,112.17 2 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/decode.go:115.2,117.17 2 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/decode.go:120.2,122.17 2 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/decode.go:125.2,126.35 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/decode.go:107.17,109.4 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/decode.go:112.17,114.4 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/decode.go:117.17,119.4 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/decode.go:122.17,124.4 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/decode.go:127.3,130.18 3 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/decode.go:133.3,134.18 1 0 +github.corp.dyndns.com/Zephyr/coredns/qstring/decode.go:130.18,132.5 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/decode.go:145.95,153.26 7 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/decode.go:161.2,162.12 2 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/decode.go:153.26,156.17 3 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/decode.go:159.3,159.54 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/decode.go:156.17,158.4 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/encode.go:18.55,22.2 3 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/encode.go:26.45,28.16 2 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/encode.go:31.2,31.27 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/encode.go:28.16,30.3 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/encode.go:40.45,41.19 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/encode.go:45.2,45.34 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/encode.go:48.2,48.56 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/encode.go:41.19,43.3 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/encode.go:45.34,47.3 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/encode.go:55.48,58.2 2 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/encode.go:60.49,62.44 2 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/encode.go:66.2,66.20 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/encode.go:62.44,64.3 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/encode.go:69.64,75.39 5 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/encode.go:103.2,103.20 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/encode.go:75.39,80.20 4 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/encode.go:87.3,87.71 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/encode.go:92.3,92.39 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/encode.go:80.20,83.4 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/encode.go:87.71,88.12 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/encode.go:93.3,94.51 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/encode.go:95.3,96.45 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/encode.go:97.3,98.66 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/encode.go:99.3,100.48 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/encode.go:106.49,108.35 2 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/encode.go:111.2,111.12 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/encode.go:108.35,110.3 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/encode.go:114.68,115.16 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/encode.go:132.2,132.11 1 0 +github.corp.dyndns.com/Zephyr/coredns/qstring/encode.go:116.2,117.24 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/encode.go:118.2,119.42 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/encode.go:120.2,121.44 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/encode.go:122.2,123.46 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/encode.go:124.2,125.57 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/encode.go:126.2,127.35 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/encode.go:128.3,129.61 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/encode.go:135.103,137.34 2 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/encode.go:153.2,153.12 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/encode.go:138.2,139.51 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/encode.go:140.2,142.22 2 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/encode.go:146.3,146.17 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/encode.go:149.3,149.31 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/encode.go:142.22,144.4 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/encode.go:146.17,148.4 1 0 +github.corp.dyndns.com/Zephyr/coredns/qstring/encode.go:149.31,151.4 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/utils.go:8.41,9.18 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/utils.go:23.2,23.14 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/utils.go:10.2,11.22 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/utils.go:12.2,13.19 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/utils.go:14.2,15.22 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/utils.go:16.2,17.23 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/utils.go:18.2,19.24 1 1 +github.corp.dyndns.com/Zephyr/coredns/qstring/utils.go:20.2,21.19 1 1 diff --git a/decode.go b/decode.go new file mode 100644 index 0000000..852dde3 --- /dev/null +++ b/decode.go @@ -0,0 +1,163 @@ +package qstring + +import ( + "net/url" + "reflect" + "strconv" + "strings" + "time" +) + +// Unmarshaller defines the interface for performing custom unmarshalling of +// query strings into struct values +type Unmarshaller interface { + UnmarshalQuery(url.Values) error +} + +// Unmarshal unmarshalls the provided url.Values (query string) into the +// interface provided +func Unmarshal(data url.Values, v interface{}) error { + var d decoder + d.init(data) + return d.unmarshal(v) +} + +// An InvalidUnmarshalError describes an invalid argument passed to Unmarshal. +// (The argument to Unmarshal must be a non-nil pointer.) +type InvalidUnmarshalError struct { + Type reflect.Type +} + +func (e InvalidUnmarshalError) Error() string { + if e.Type == nil { + return "qstring: Unmarshal(nil)" + } + + if e.Type.Kind() != reflect.Ptr { + return "qstring: Unmarshal(non-pointer " + e.Type.String() + ")" + } + return "qstring: Unmarshal(nil " + e.Type.String() + ")" +} + +type decoder struct { + data url.Values +} + +func (d *decoder) init(data url.Values) *decoder { + d.data = data + return d +} + +func (d *decoder) unmarshal(v interface{}) error { + rv := reflect.ValueOf(v) + if rv.Kind() != reflect.Ptr || rv.IsNil() { + return &InvalidUnmarshalError{reflect.TypeOf(v)} + } + + return d.value(rv) +} + +func (d *decoder) value(val reflect.Value) error { + elem := val.Elem() + typ := elem.Type() + for i := 0; i < elem.NumField(); i++ { + // pull out the qstring struct tag + elemField := elem.Field(i) + typField := typ.Field(i) + qstring := typField.Tag.Get(Tag) + if qstring == "" { + // resolvable fields must have at least the `flag` struct tag + qstring = strings.ToLower(typField.Name) + } + + // determine if this is an unsettable field or was explicitly set to be + // ignored + if !elemField.CanSet() || qstring == "-" { + continue + } + + // only do work if the current fields query string parameter was provided + if query, ok := d.data[qstring]; ok { + switch k := typField.Type.Kind(); k { + case reflect.Slice: + d.coerceSlice(query, k, elemField) + default: + d.coerce(query[0], k, elemField) + } + } else if typField.Type.Kind() == reflect.Struct { + if elemField.CanAddr() { + d.value(elemField.Addr()) + } + } + } + return nil +} + +// coerce converts the provided query parameter slice into the proper type for +// the target field. this coerced value is then assigned to the current field +func (d *decoder) coerce(query string, target reflect.Kind, field reflect.Value) error { + var err error + var c interface{} + + switch target { + case reflect.String: + field.SetString(query) + case reflect.Bool: + c, err = strconv.ParseBool(query) + if err == nil { + field.SetBool(c.(bool)) + } + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + c, err = strconv.ParseInt(query, 10, 64) + if err == nil { + field.SetInt(c.(int64)) + } + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + c, err = strconv.ParseUint(query, 10, 64) + if err == nil { + field.SetUint(c.(uint64)) + } + case reflect.Float32, reflect.Float64: + c, err = strconv.ParseFloat(query, 64) + if err == nil { + field.SetFloat(c.(float64)) + } + case reflect.Struct: + switch field.Interface().(type) { + case time.Time: + var t time.Time + t, err = time.Parse(time.RFC3339, query) + if err == nil { + field.Set(reflect.ValueOf(t)) + } + default: + d.value(field) + } + } + + return err +} + +// coerceSlice creates a new slice of the appropriate type for the target field +// and coerces each of the query parameter values into the destination type. +// Should any of the provided query parameters fail to be coerced, an error is +// returned and the entire slice will not be applied +func (d *decoder) coerceSlice(query []string, target reflect.Kind, field reflect.Value) error { + var err error + sliceType := field.Type().Elem() + coerceKind := sliceType.Kind() + sl := reflect.MakeSlice(reflect.SliceOf(sliceType), 0, 0) + // Create a pointer to a slice value and set it to the slice + slice := reflect.New(sl.Type()) + slice.Elem().Set(sl) + for _, q := range query { + val := reflect.New(sliceType).Elem() + err = d.coerce(q, coerceKind, val) + if err != nil { + return err + } + slice.Elem().Set(reflect.Append(slice.Elem(), val)) + } + field.Set(slice.Elem()) + return nil +} diff --git a/decode_test.go b/decode_test.go new file mode 100644 index 0000000..ac08252 --- /dev/null +++ b/decode_test.go @@ -0,0 +1,181 @@ +package qstring + +import ( + "net/url" + "testing" + "time" +) + +type TestStruct struct { + Name string `qstring:"name"` + Do bool + + // int fields + Page int `qstring:"page"` + ID int8 + Small int16 + Med int32 + Big int64 + + // uint fields + UPage uint + UID uint8 + USmall uint16 + UMed uint32 + UBig uint64 + + // Floats + Float32 float32 + Float64 float64 + + // slice fields + Fields []string `qstring:"fields"` + DoFields []bool `qstring:"dofields"` + Counts []int + IDs []int8 + Smalls []int16 + Meds []int32 + Bigs []int64 + + // uint fields + UPages []uint + UIDs []uint8 + USmalls []uint16 + UMeds []uint32 + UBigs []uint64 + + // Floats + Float32s []float32 + Float64s []float64 + hidden int + Hidden int `qstring:"-"` +} + +func TestUnmarshall(t *testing.T) { + var ts TestStruct + query := url.Values{ + "name": []string{"SomeName"}, + "do": []string{"true"}, + "page": []string{"1"}, + "id": []string{"12"}, + "small": []string{"13"}, + "med": []string{"14"}, + "big": []string{"15"}, + "upage": []string{"2"}, + "uid": []string{"16"}, + "usmall": []string{"17"}, + "umed": []string{"18"}, + "ubig": []string{"19"}, + "float32": []string{"6000"}, + "float64": []string{"7000"}, + "fields": []string{"foo", "bar"}, + "dofields": []string{"true", "false"}, + "counts": []string{"1", "2"}, + "ids": []string{"3", "4", "5"}, + "smalls": []string{"6", "7", "8"}, + "meds": []string{"9", "10", "11"}, + "bigs": []string{"12", "13", "14"}, + "upages": []string{"2", "3", "4"}, + "uids": []string{"5, 6, 7"}, + "usmalls": []string{"8", "9", "10"}, + "umeds": []string{"9", "10", "11"}, + "ubigs": []string{"12", "13", "14"}, + "float32s": []string{"6000", "6001", "6002"}, + "float64s": []string{"7000", "7001", "7002"}, + } + + err := Unmarshal(query, &ts) + if err != nil { + t.Fatal(err.Error()) + } + + if ts.Page != 1 { + t.Errorf("Expected page to be 1, got %d", ts.Page) + } + + if len(ts.Fields) != 2 { + t.Errorf("Expected 2 fields, got %d", len(ts.Fields)) + } +} + +func TestUnmarshalNested(t *testing.T) { + type Paging struct { + Page int + Limit int + } + + type Params struct { + Paging Paging + Name string + } + + query := url.Values{ + "name": []string{"SomeName"}, + "page": []string{"1"}, + "limit": []string{"50"}, + } + + params := &Params{} + + err := Unmarshal(query, params) + if err != nil { + t.Fatal(err.Error()) + } + + if params.Paging.Page != 1 { + t.Errorf("Nested Struct Failed to Unmarshal. Expected 1, got %d", params.Paging.Page) + } +} + +func TestUnmarshalTime(t *testing.T) { + type Query struct { + Created time.Time + LastUpdated time.Time + } + + createdTS := "2006-01-02T15:04:05Z" + updatedTS := "2016-01-02T15:04:05-07:00" + + query := url.Values{ + "created": []string{createdTS}, + "lastupdated": []string{updatedTS}, + } + + params := &Query{} + err := Unmarshal(query, params) + if err != nil { + t.Fatal(err.Error()) + } + + if params.Created.Format(time.RFC3339) != createdTS { + t.Errorf("Expected created ts of %s, got %s instead.", createdTS, params.Created.Format(time.RFC3339)) + } + + if params.LastUpdated.Format(time.RFC3339) != updatedTS { + t.Errorf("Expected update ts of %s, got %s instead.", updatedTS, params.LastUpdated.Format(time.RFC3339)) + } +} + +func TestUnmarshalInvalidTypes(t *testing.T) { + var err error + var ts *TestStruct + testio := []struct { + inp interface{} + errString string + }{ + {inp: nil, errString: "qstring: Unmarshal(nil)"}, + {inp: TestStruct{}, errString: "qstring: Unmarshal(non-pointer qstring.TestStruct)"}, + {inp: ts, errString: "qstring: Unmarshal(nil *qstring.TestStruct)"}, + } + + for _, test := range testio { + err = Unmarshal(url.Values{}, test.inp) + if err == nil { + t.Errorf("Expected invalid type error, got success instead") + } + + if err.Error() != test.errString { + t.Errorf("Got %q error, expected %q", err.Error(), test.errString) + } + } +} diff --git a/encode.go b/encode.go new file mode 100644 index 0000000..0b586f9 --- /dev/null +++ b/encode.go @@ -0,0 +1,154 @@ +package qstring + +import ( + "net/url" + "reflect" + "strconv" + "strings" + "time" +) + +// Marshaller defines the interface for performing custom marshalling of struct +// values into query strings +type Marshaller interface { + MarshalQuery() (url.Values, error) +} + +// MarshalValues marshals the provided struct into a url.Values collection +func MarshalValues(v interface{}) (url.Values, error) { + var e encoder + e.init(v) + return e.marshal() +} + +// Marshal marshals the provided struct into a raw query string and returns a +// conditional error +func Marshal(v interface{}) (string, error) { + vals, err := MarshalValues(v) + if err != nil { + return "", err + } + return vals.Encode(), nil +} + +// An InvalidMarshalError describes an invalid argument passed to Marshal or +// MarshalValue. (The argument to Marshal must be a non-nil pointer.) +type InvalidMarshalError struct { + Type reflect.Type +} + +func (e InvalidMarshalError) Error() string { + if e.Type == nil { + return "qstring: Marshal(nil)" + } + + if e.Type.Kind() != reflect.Ptr { + return "qstring: Marshal(non-pointer " + e.Type.String() + ")" + } + return "qstring: Marshal(nil " + e.Type.String() + ")" +} + +type encoder struct { + data interface{} +} + +func (e *encoder) init(v interface{}) *encoder { + e.data = v + return e +} + +func (e *encoder) marshal() (url.Values, error) { + rv := reflect.ValueOf(e.data) + if rv.Kind() != reflect.Ptr || rv.IsNil() { + return nil, &InvalidMarshalError{reflect.TypeOf(e.data)} + } + + return e.value(rv) +} + +func (e *encoder) value(val reflect.Value) (url.Values, error) { + elem := val.Elem() + typ := elem.Type() + + var err error + var output = make(url.Values) + for i := 0; i < elem.NumField(); i++ { + // pull out the qstring struct tag + elemField := elem.Field(i) + typField := typ.Field(i) + qstring := typField.Tag.Get(Tag) + if qstring == "" { + // resolvable fields must have at least the `flag` struct tag + qstring = strings.ToLower(typField.Name) + } + + // determine if this is an unsettable field or was explicitly set to be + // ignored + if !elemField.CanSet() || qstring == "-" || isEmptyValue(elemField) { + continue + } + + // only do work if the current fields query string parameter was provided + switch k := typField.Type.Kind(); k { + default: + output.Set(qstring, marshalValue(elemField, k)) + case reflect.Slice: + output[qstring] = marshalSlice(elemField) + case reflect.Ptr: + marshalStruct(output, qstring, reflect.Indirect(elemField), k) + case reflect.Struct: + marshalStruct(output, qstring, elemField, k) + } + } + return output, err +} + +func marshalSlice(field reflect.Value) []string { + var out []string + for i := 0; i < field.Len(); i++ { + out = append(out, marshalValue(field.Index(i), field.Index(i).Kind())) + } + return out +} + +func marshalValue(field reflect.Value, source reflect.Kind) string { + switch source { + case reflect.String: + return field.String() + case reflect.Bool: + return strconv.FormatBool(field.Bool()) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return strconv.FormatInt(field.Int(), 10) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return strconv.FormatUint(field.Uint(), 10) + case reflect.Float32, reflect.Float64: + return strconv.FormatFloat(field.Float(), 'G', -1, 64) + case reflect.Struct: + switch field.Interface().(type) { + case time.Time: + return field.Interface().(time.Time).Format(time.RFC3339) + } + } + return "" +} + +func marshalStruct(output url.Values, qstring string, field reflect.Value, source reflect.Kind) error { + var err error + switch field.Interface().(type) { + case time.Time: + output.Set(qstring, marshalValue(field, source)) + default: + var vals url.Values + if field.CanAddr() { + vals, err = MarshalValues(field.Addr().Interface()) + } + + if err != nil { + return err + } + for key, list := range vals { + output[key] = list + } + } + return nil +} diff --git a/encode_test.go b/encode_test.go new file mode 100644 index 0000000..dd3990a --- /dev/null +++ b/encode_test.go @@ -0,0 +1,268 @@ +package qstring + +import ( + "net/url" + "strings" + "testing" + "time" +) + +func TestMarshallString(t *testing.T) { + ts := TestStruct{ + Name: "SomeName", + Do: true, + Page: 1, + ID: 12, + Small: 13, + Med: 14, + Big: 15, + UPage: 2, + UID: 16, + USmall: 17, + UMed: 17, + UBig: 17, + Float32: 6000, + Float64: 7000, + Fields: []string{"foo", "bar"}, + DoFields: []bool{true, false}, + Counts: []int{1, 2}, + IDs: []int8{3, 4}, + Smalls: []int16{6, 7}, + Meds: []int32{9, 10}, + Bigs: []int64{12, 13}, + UPages: []uint{2, 3}, + UIDs: []uint8{5, 6}, + USmalls: []uint16{8, 9}, + UMeds: []uint32{9, 10}, + UBigs: []uint64{12, 13}, + Float32s: []float32{6000, 6001}, + Float64s: []float64{7000, 7001}, + } + + expected := []string{"name=SomeName", "do=true", "page=1", "id=12", "small=13", + "med=14", "big=15", "upage=2", "uid=16", "usmall=17", "umed=17", "ubig=17", + "float32=6000", "float64=7000", "fields=foo", "fields=bar", "dofields=true", + "dofields=false", "counts=1", "counts=2", "ids=3", "ids=4", "smalls=6", + "smalls=7", "meds=9", "meds=10", "bigs=12", "bigs=13", "upages=2", + "upages=3", "uids=5", "uids=6", "usmalls=8", "usmalls=9", "umeds=9", + "umeds=10", "ubigs=12", "ubigs=13", "float32s=6000", "float32s=6001", + "float64s=7000", "float64s=7001"} + query, err := Marshal(&ts) + if err != nil { + t.Fatal(err.Error()) + } + + for _, param := range expected { + if !strings.Contains(query, param) { + t.Errorf("Expected %s to contain %s", query, param) + } + } +} + +func TestMarshallValues(t *testing.T) { + ts := TestStruct{ + Name: "SomeName", + Do: true, + Page: 1, + ID: 12, + Small: 13, + Med: 14, + Big: 15, + UPage: 2, + UID: 16, + USmall: 17, + UMed: 17, + UBig: 17, + Float32: 6000, + Float64: 7000, + Fields: []string{"foo", "bar"}, + DoFields: []bool{true, false}, + Counts: []int{1, 2}, + IDs: []int8{3, 4}, + Smalls: []int16{6, 7}, + Meds: []int32{9, 10}, + Bigs: []int64{12, 13}, + UPages: []uint{2, 3}, + UIDs: []uint8{5, 6}, + USmalls: []uint16{8, 9}, + UMeds: []uint32{9, 10}, + UBigs: []uint64{12, 13}, + Float32s: []float32{6000, 6001}, + Float64s: []float64{7000, 7001}, + } + + expected := url.Values{ + "name": []string{"SomeName"}, + "do": []string{"true"}, + "page": []string{"1"}, + "id": []string{"12"}, + "small": []string{"13"}, + "med": []string{"14"}, + "big": []string{"15"}, + "upage": []string{"2"}, + "uid": []string{"16"}, + "usmall": []string{"17"}, + "umed": []string{"18"}, + "ubig": []string{"19"}, + "float32": []string{"6000"}, + "float64": []string{"7000"}, + "fields": []string{"foo", "bar"}, + "dofields": []string{"true", "false"}, + "counts": []string{"1", "2"}, + "ids": []string{"3", "4", "5"}, + "smalls": []string{"6", "7", "8"}, + "meds": []string{"9", "10", "11"}, + "bigs": []string{"12", "13", "14"}, + "upages": []string{"2", "3", "4"}, + "uids": []string{"5, 6, 7"}, + "usmalls": []string{"8", "9", "10"}, + "umeds": []string{"9", "10", "11"}, + "ubigs": []string{"12", "13", "14"}, + "float32s": []string{"6000", "6001", "6002"}, + "float64s": []string{"7000", "7001", "7002"}, + } + values, err := MarshalValues(&ts) + if err != nil { + t.Fatal(err.Error()) + } + + if len(values) != len(expected) { + t.Errorf("Expected %d fields, got %d", len(expected), len(values)) + } +} + +func TestInvalidMarshal(t *testing.T) { + var err error + var ts *TestStruct + testio := []struct { + inp interface{} + errString string + }{ + {inp: nil, errString: "qstring: Marshal(nil)"}, + {inp: TestStruct{}, errString: "qstring: Marshal(non-pointer qstring.TestStruct)"}, + {inp: ts, errString: "qstring: Marshal(nil *qstring.TestStruct)"}, + } + + for _, test := range testio { + _, err = Marshal(test.inp) + if err == nil { + t.Errorf("Expected invalid type error, got success instead") + } + + if err.Error() != test.errString { + t.Errorf("Got %q error, expected %q", err.Error(), test.errString) + } + } +} + +func TestMarshalTime(t *testing.T) { + type Query struct { + Created time.Time + LastUpdated time.Time + } + + createdTS := "2006-01-02T15:04:05Z" + createdTime, _ := time.Parse(time.RFC3339, createdTS) + updatedTS := "2016-01-02T15:04:05-07:00" + updatedTime, _ := time.Parse(time.RFC3339, updatedTS) + + q := &Query{Created: createdTime, LastUpdated: updatedTime} + result, err := Marshal(q) + if err != nil { + t.Fatalf("Unable to marshal timestamp: %s", err.Error()) + } + + var unescaped string + unescaped, err = url.QueryUnescape(result) + if err != nil { + t.Fatalf("Unable to unescape query string %q: %q", result, err.Error()) + } + + expected := []string{"created=2006-01-02T15:04:05Z", + "lastupdated=2016-01-02T15:04:05-07:00"} + for _, ts := range expected { + if !strings.Contains(unescaped, ts) { + t.Errorf("Expected query string %s to contain %s", unescaped, ts) + } + } +} + +func TestMarshalNested(t *testing.T) { + type Paging struct { + Page int + Limit int + } + + type Params struct { + Paging Paging + Name string + } + + params := &Params{Name: "SomeName", + Paging: Paging{Page: 1, Limit: 50}, + } + + result, err := Marshal(params) + if err != nil { + t.Fatalf("Unable to marshal nested struct: %s", err.Error()) + } + + var unescaped string + unescaped, err = url.QueryUnescape(result) + if err != nil { + t.Fatalf("Unable to unescape query string %q: %q", result, err.Error()) + } + + // ensure the nested struct isn't iteself included in the query string + if strings.Contains(unescaped, "paging=") { + t.Errorf("Nested struct was included in %q", unescaped) + } + + // ensure fields we expect to be present are + expected := []string{"name=SomeName", "page=1", "limit=50"} + for _, q := range expected { + if !strings.Contains(unescaped, q) { + t.Errorf("Expected query string %s to contain %s", unescaped, q) + } + } +} + +func TestMarshalNestedPtrs(t *testing.T) { + type Paging struct { + Page int + Limit int + } + + type Params struct { + Paging *Paging + Name string + } + + params := &Params{Name: "SomeName", + Paging: &Paging{Page: 1, Limit: 50}, + } + + result, err := Marshal(params) + if err != nil { + t.Fatalf("Unable to marshal nested struct: %s", err.Error()) + } + + var unescaped string + unescaped, err = url.QueryUnescape(result) + if err != nil { + t.Fatalf("Unable to unescape query string %q: %q", result, err.Error()) + } + + // ensure the nested struct isn't iteself included in the query string + if strings.Contains(unescaped, "paging=") { + t.Errorf("Nested struct was included in %q", unescaped) + } + + // ensure fields we expect to be present are + expected := []string{"name=SomeName", "page=1", "limit=50"} + for _, q := range expected { + if !strings.Contains(unescaped, q) { + t.Errorf("Expected query string %s to contain %s", unescaped, q) + } + } +} diff --git a/qstring.go b/qstring.go new file mode 100644 index 0000000..d4c2144 --- /dev/null +++ b/qstring.go @@ -0,0 +1,7 @@ +package qstring + +const ( + // Tag indicates the name of the struct tag to extract from provided structs + // when marshalling or unmarshalling + Tag = "qstring" +) diff --git a/utils.go b/utils.go new file mode 100644 index 0000000..492f773 --- /dev/null +++ b/utils.go @@ -0,0 +1,24 @@ +package qstring + +import ( + "reflect" +) + +// isEmptyValue returns true if the provided reflect.Value +func isEmptyValue(v reflect.Value) bool { + switch v.Kind() { + case reflect.Array, reflect.Map, reflect.Slice, reflect.String: + return v.Len() == 0 + case reflect.Bool: + return !v.Bool() + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return v.Int() == 0 + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + return v.Uint() == 0 + case reflect.Float32, reflect.Float64: + return v.Float() == 0 + case reflect.Interface, reflect.Ptr: + return v.IsNil() + } + return false +} diff --git a/utils_test.go b/utils_test.go new file mode 100644 index 0000000..d115c45 --- /dev/null +++ b/utils_test.go @@ -0,0 +1,39 @@ +package qstring + +import ( + "reflect" + "testing" +) + +func TestIsEmptyValue(t *testing.T) { + var ts *TestStruct + ts = nil + testIO := []struct { + inp reflect.Value + expected bool + }{ + {inp: reflect.ValueOf([]int{0}), expected: false}, + {inp: reflect.ValueOf([]int{}), expected: true}, + {inp: reflect.ValueOf(map[string]int{"a": 0}), expected: false}, + {inp: reflect.ValueOf(map[string]int{}), expected: true}, + {inp: reflect.ValueOf(false), expected: true}, + {inp: reflect.ValueOf(true), expected: false}, + {inp: reflect.ValueOf(5), expected: false}, + {inp: reflect.ValueOf(0), expected: true}, + {inp: reflect.ValueOf(uint(5)), expected: false}, + {inp: reflect.ValueOf(uint(0)), expected: true}, + {inp: reflect.ValueOf(float32(5)), expected: false}, + {inp: reflect.ValueOf(float32(0)), expected: true}, + {inp: reflect.ValueOf(&TestStruct{}), expected: false}, + {inp: reflect.ValueOf(ts), expected: true}, + {inp: reflect.ValueOf(nil), expected: false}, + } + + var result bool + for _, test := range testIO { + result = isEmptyValue(test.inp) + if result != test.expected { + t.Errorf("Expected %t for input %s, got %t", test.expected, test.inp, result) + } + } +}