diff --git a/README.md b/README.md index 9ac0a05..8cf7cd2 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,14 @@ # qstring -This package provides an easy way to marshal and unmarshal url query string data to -and from structs. + +This package provides an easy way to marshal and unmarshal url query string data to and from structs. + +This was originally forked from [dyninc/qstring](https://github.com/dyninc/qstring) but it seems to have become inactive +and I wanted some extra features so I am now maintaining this fork. ## Installation + ```bash -$ go get github.com/dyninc/qstring +$ go get github.com/Southclaws/qstring ``` ## Examples @@ -17,7 +21,7 @@ package main import ( "net/http" - "github.com/dyninc/qstring" + "github.com/Southclaws/qstring" ) // Query is the http request query struct. @@ -38,10 +42,9 @@ func handler(w http.ResponseWriter, req *http.Request) { } ``` -The above example will unmarshal the query string from an http.Request and -unmarshal it into the provided struct. This means that a query of -`?names=foo&names=bar&limit=50&page=1` would be unmarshaled into a struct similar -to the following: +The above example will unmarshal the query string from an http.Request and unmarshal it into the provided struct. This +means that a query of `?names=foo&names=bar&limit=50&page=1` would be unmarshaled into a struct similar to the +following: ```go Query{ @@ -52,11 +55,12 @@ Query{ ``` ### Marshalling -`qstring` also exposes two methods of Marshaling structs *into* Query parameters, -one will Marshal the provided struct into a raw query string, the other will -Marshal a struct into a `url.Values` type. Some Examples of both follow. + +`qstring` also exposes two methods of Marshaling structs _into_ Query parameters, one will Marshal the provided struct +into a raw query string, the other will Marshal a struct into a `url.Values` type. Some Examples of both follow. ### Marshal Raw Query String + ```go package main @@ -64,7 +68,7 @@ import ( "fmt" "net/http" - "github.com/dyninc/qstring" + "github.com/Southclaws/qstring" ) // Query is the http request query struct. @@ -87,6 +91,7 @@ func main() { ``` ### Marshal url.Values + ```go package main @@ -94,7 +99,7 @@ import ( "fmt" "net/http" - "github.com/dyninc/qstring" + "github.com/Southclaws/qstring" ) // Query is the http request query struct. @@ -117,8 +122,8 @@ func main() { ``` ### Nested -In the same spirit as other Unmarshaling libraries, `qstring` allows you to -Marshal/Unmarshal nested structs + +In the same spirit as other Unmarshaling libraries, `qstring` allows you to Marshal/Unmarshal nested structs ```go package main @@ -126,7 +131,7 @@ package main import ( "net/http" - "github.com/dyninc/qstring" + "github.com/Southclaws/qstring" ) // PagingParams represents common pagination information for query strings @@ -143,9 +148,9 @@ type Query struct { ``` ### Complex Structures -Again, in the spirit of other Unmarshaling libraries, `qstring` allows for some -more complex types, such as pointers and time.Time fields. A more complete -example might look something like the following code snippet + +Again, in the spirit of other Unmarshaling libraries, `qstring` allows for some more complex types, such as pointers and +time.Time fields. A more complete example might look something like the following code snippet ```go package main @@ -171,25 +176,29 @@ type Query struct { ``` ## Additional Notes -* All Timestamps are assumed to be in RFC3339 format -* A struct field tag of `qstring` is supported and supports all of the features -you've come to know and love from Go (un)marshalers. - * A field tag with a value of `qstring:"-"` instructs `qstring` to ignore the field. - * A field tag with an the `omitempty` option set will be ignored if the field - being marshaled has a zero value. `qstring:"name,omitempty"` + +- All Timestamps are assumed to be in RFC3339 format +- A struct field tag of `qstring` is supported and supports all of the features you've come to know and love from Go + (un)marshalers. + - A field tag with a value of `qstring:"-"` instructs `qstring` to ignore the field. + - A field tag with an the `omitempty` option set will be ignored if the field being marshaled has a zero value. + `qstring:"name,omitempty"` + - A slice field tag with the `comma` option set will produce a comma-separated list of items in a single query + parameter instead of multiple instances of the parameter. `qstring:"names,comma"` will produce `?names=a,b,c` + instead of `?names=a&names=b&names=c`. ### Custom Fields -In order to facilitate more complex queries `qstring` also provides some custom -fields to save you a bit of headache with custom marshal/unmarshaling logic. -Currently the following custom fields are provided: -* `qstring.ComparativeTime` - Supports timestamp query parameters with optional -logical operators (<, >, <=, >=) such as `?created<=2006-01-02T15:04:05Z` +In order to facilitate more complex queries `qstring` also provides some custom fields to save you a bit of headache +with custom marshal/unmarshaling logic. Currently the following custom fields are provided: +- `qstring.ComparativeTime` - Supports timestamp query parameters with optional logical operators (<, >, <=, >=) such as + `?created<=2006-01-02T15:04:05Z` ## Benchmarks + ``` BenchmarkUnmarshall-4 500000 2711 ns/op 448 B/op 23 allocs/op BenchmarkRawPLiteral-4 1000000 1675 ns/op 448 B/op 23 allocs/op -ok github.com/dyninc/qstring 3.163s +ok github.com/Southclaws/qstring 3.163s ``` diff --git a/decode.go b/decode.go index f4f9130..d573859 100644 --- a/decode.go +++ b/decode.go @@ -71,7 +71,7 @@ func (d *decoder) value(val reflect.Value) error { // pull out the qstring struct tag elemField := elem.Field(i) typField := typ.Field(i) - qstring, _ := parseTag(typField.Tag.Get(Tag)) + qstring, _, comma := parseTag(typField.Tag.Get(Tag)) if qstring == "" { // resolvable fields must have at least the `flag` struct tag qstring = strings.ToLower(typField.Name) @@ -87,7 +87,7 @@ func (d *decoder) value(val reflect.Value) error { if query, ok := d.data[qstring]; ok { switch k := typField.Type.Kind(); k { case reflect.Slice: - err = d.coerceSlice(query, k, elemField) + err = d.coerceSlice(query, k, elemField, comma) default: err = d.coerce(query[0], k, elemField) } @@ -164,7 +164,7 @@ func (d *decoder) coerce(query string, target reflect.Kind, field reflect.Value) // 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 { +func (d *decoder) coerceSlice(query []string, target reflect.Kind, field reflect.Value, comma bool) error { var err error sliceType := field.Type().Elem() coerceKind := sliceType.Kind() @@ -172,7 +172,13 @@ func (d *decoder) coerceSlice(query []string, target reflect.Kind, field reflect // 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 { + var elements []string + if comma && len(query) > 0 { + elements = strings.Split(query[0], ",") + } else { + elements = query + } + for _, q := range elements { val := reflect.New(sliceType).Elem() err = d.coerce(q, coerceKind, val) if err != nil { diff --git a/decode_test.go b/decode_test.go index 1c8b04e..08774c2 100644 --- a/decode_test.go +++ b/decode_test.go @@ -30,13 +30,21 @@ type TestStruct struct { Float64 float64 // slice fields - Fields []string `qstring:"fields"` - DoFields []bool `qstring:"dofields"` - Counts []int - IDs []int8 - Smalls []int16 - Meds []int32 - Bigs []int64 + Fields []string `qstring:"fields"` + DoFields []bool `qstring:"dofields"` + Counts []int + IDs []int8 + Smalls []int16 + Meds []int32 + Bigs []int64 + FieldsC []string `qstring:"fieldsc,comma"` + DoFieldsC []bool `qstring:"dofieldsc,comma"` + CountsC []int `qstring:",comma"` + IDsC []int8 `qstring:",comma"` + SmallsC []int16 `qstring:",comma"` + MedsC []int32 `qstring:",comma"` + BigsC []int64 `qstring:",comma"` + Float32sC []float32 `qstring:",comma"` // uint fields UPages []uint @@ -55,34 +63,42 @@ type TestStruct struct { 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"}, + "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"}, + "fieldsc": []string{"foo,bar"}, + "dofieldsc": []string{"true,false"}, + "countsc": []string{"1,2"}, + "idsc": []string{"3,4,5"}, + "smallsc": []string{"6,7,8"}, + "medsc": []string{"9,10,11"}, + "bigsc": []string{"12,13,14"}, + "float32sc": []string{"1.1,2.2,3.3"}, + "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) @@ -97,6 +113,26 @@ func TestUnmarshall(t *testing.T) { if len(ts.Fields) != 2 { t.Errorf("Expected 2 fields, got %d", len(ts.Fields)) } + + if len(ts.FieldsC) != 2 { + t.Errorf("Expected 2 fields, got %d", len(ts.FieldsC)) + } + + if len(ts.DoFieldsC) != 2 { + t.Errorf("Expected 2 fields, got %d", len(ts.DoFieldsC)) + } + + if len(ts.CountsC) != 2 { + t.Errorf("Expected 2 fields, got %d", len(ts.CountsC)) + } + + if len(ts.IDsC) != 3 { + t.Errorf("Expected 3 fields, got %d", len(ts.IDsC)) + } + + if len(ts.Float32sC) != 3 { + t.Errorf("Expected 3 fields, got %d", len(ts.IDsC)) + } } func TestUnmarshalNested(t *testing.T) { diff --git a/doc_test.go b/doc_test.go index 7acbcac..942ae81 100644 --- a/doc_test.go +++ b/doc_test.go @@ -6,7 +6,7 @@ import ( "os" "time" - "github.com/dyninc/qstring" + "github.com/Southclaws/qstring" ) func ExampleUnmarshal() { diff --git a/encode.go b/encode.go index 9bc437a..741c39f 100644 --- a/encode.go +++ b/encode.go @@ -81,7 +81,7 @@ func (e *encoder) value(val reflect.Value) (url.Values, error) { // pull out the qstring struct tag elemField := elem.Field(i) typField := typ.Field(i) - qstring, omit := parseTag(typField.Tag.Get(Tag)) + qstring, omit, comma := parseTag(typField.Tag.Get(Tag)) if qstring == "" { // resolvable fields must have at least the `flag` struct tag qstring = strings.ToLower(typField.Name) @@ -98,7 +98,7 @@ func (e *encoder) value(val reflect.Value) (url.Values, error) { default: output.Set(qstring, marshalValue(elemField, k)) case reflect.Slice: - output[qstring] = marshalSlice(elemField) + output[qstring] = marshalSlice(elemField, comma) case reflect.Ptr: marshalStruct(output, qstring, reflect.Indirect(elemField), k) case reflect.Struct: @@ -108,11 +108,14 @@ func (e *encoder) value(val reflect.Value) (url.Values, error) { return output, err } -func marshalSlice(field reflect.Value) []string { +func marshalSlice(field reflect.Value, comma bool) []string { var out []string for i := 0; i < field.Len(); i++ { out = append(out, marshalValue(field.Index(i), field.Index(i).Kind())) } + if comma { + return []string{strings.Join(out, ",")} + } return out } diff --git a/encode_test.go b/encode_test.go index cd12cd5..2baa911 100644 --- a/encode_test.go +++ b/encode_test.go @@ -92,34 +92,42 @@ func TestMarshallValues(t *testing.T) { } 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"}, + "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"}, + "fieldsc": []string{"foo,bar"}, + "dofieldsc": []string{"true,false"}, + "countsc": []string{"1,2"}, + "idsc": []string{"3,4,5"}, + "smallsc": []string{"6,7,8"}, + "medsc": []string{"9,10,11"}, + "bigsc": []string{"12,13,14"}, + "float32sc": []string{"1.1,2.2,3.3"}, + "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 := Marshal(&ts) if err != nil { diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0f478ca --- /dev/null +++ b/go.mod @@ -0,0 +1 @@ +module github.com/Southclaws/qstring diff --git a/utils.go b/utils.go index 7e3aecf..5ef7d7c 100644 --- a/utils.go +++ b/utils.go @@ -34,13 +34,21 @@ func isEmptyValue(v reflect.Value) bool { // parseTag splits a struct field's qstring tag into its name and, if an // optional omitempty option was provided, a boolean indicating this is -// returned -func parseTag(tag string) (string, bool) { - if idx := strings.Index(tag, ","); idx != -1 { - if tag[idx+1:] == "omitempty" { - return tag[:idx], true +// returned and a boolean +func parseTag(tag string) (name string, omitempty bool, comma bool) { + elements := strings.Split(tag, ",") + if len(elements) == 1 { + return tag, false, false + } + + name = elements[0] + for _, opt := range elements[1:] { + if opt == "omitempty" { + omitempty = true + } + if opt == "comma" { + comma = true } - return tag[:idx], false } - return tag, false + return } diff --git a/utils_test.go b/utils_test.go index 8e2914e..284525f 100644 --- a/utils_test.go +++ b/utils_test.go @@ -49,20 +49,25 @@ func TestTagParsing(t *testing.T) { inp string output string omit bool + comma bool }{ - {inp: "name,omitempty", output: "name", omit: true}, - {inp: "name", output: "name", omit: false}, - {inp: "name,", output: "name", omit: false}, - {inp: "name", output: "name", omit: false}, - {inp: "", output: "", omit: false}, - {inp: ",omitempty", output: "", omit: true}, - {inp: "-", output: "-", omit: false}, + {inp: "name,omitempty", output: "name", omit: true, comma: false}, + {inp: "name", output: "name", omit: false, comma: false}, + {inp: "name,", output: "name", omit: false, comma: false}, + {inp: "name", output: "name", omit: false, comma: false}, + {inp: "", output: "", omit: false, comma: false}, + {inp: ",omitempty", output: "", omit: true, comma: false}, + {inp: "-", output: "-", omit: false, comma: false}, + {inp: "name,omitempty,comma", output: "name", omit: true, comma: true}, + {inp: "name,comma", output: "name", omit: false, comma: true}, + {inp: "name,comma,", output: "name", omit: false, comma: true}, } var name string var omit bool + var comma bool for _, test := range testio { - name, omit = parseTag(test.inp) + name, omit, comma = parseTag(test.inp) if name != test.output { t.Errorf("Expected tag name to be %q, got %q instead", test.output, name) } @@ -70,6 +75,10 @@ func TestTagParsing(t *testing.T) { if omit != test.omit { t.Errorf("Expected omitempty to be %t, got %t instead", test.omit, omit) } + + if comma != test.comma { + t.Errorf("Expected comma to be %t, got %t instead", test.comma, comma) + } } }