diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 05dddcd..8ea4f38 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -22,19 +22,26 @@ jobs: - name: Check out code into the Go module directory uses: actions/checkout@v4 - - name: Get dependencies + - name: Download dependencies + run: go mod download + + - name: Verify formatting run: | - go get -v -t -d ./... - if [ -f Gopkg.toml ]; then - curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh - dep ensure + unformatted=$(gofmt -l .) + if [ -n "$unformatted" ]; then + echo "These files are not gofmt-formatted:" + echo "$unformatted" + exit 1 fi + - name: Vet + run: go vet ./... + - name: Build - run: go build -v . + run: go build -v ./... - name: Test - run: go test -v -coverprofile=coverage.out . + run: go test -count=3 -v -coverprofile=coverage.out . - name: Codecov uses: codecov/codecov-action@v4 diff --git a/README.md b/README.md index b19cfb1..c32ac77 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,19 @@ values, _ := encoder.Values(query) fmt.Println(values.Encode()) //(unescaped) output: "user[from]=1601623397728&user[verified]=true" ``` +By default, it uses the brackets, add the `dot` option to +a nested struct field to scope its children with `.`: +```go +type Query struct { + User User `qs:"user,dot"` +} + +values, _ := encoder.Values(query) +fmt.Println(values.Encode()) //(unescaped) output: "user.from=1601623397728&user.verified=true" +``` +The `dot` option applies only to the field it is declared on; nest it again on a +deeper struct field to keep using dots (otherwise that level falls back to brackets). + ### Custom Type Implement funcs: * `EncodeParam` to encode itself into query param. diff --git a/doc.go b/doc.go index 0044276..c93f280 100644 --- a/doc.go +++ b/doc.go @@ -13,12 +13,12 @@ Use `WithTagAlias()` func to register custom tag alias (default is `qs`) Encoder has `.Values()` and `Encode()` functions to encode structs into url.Values. Supported data types: - - all basic types (`bool`, `uint`, `string`, `float64`,...) - - struct - - slice/array - - pointer - - time.Time - - custom type + - all basic types (`bool`, `uint`, `string`, `float64`,...) + - struct + - slice/array + - pointer + - time.Time + - custom type Example @@ -107,7 +107,6 @@ Including the `bracket` option to signal that the multiple URL values should hav values, _ := encoder.Values(&Query{Tags: []string{"foo","bar"}}) fmt.Println(values.Encode()) //(unescaped) output: "tags[]=foo&tags[]=bar" - The `index` option will append an index number with brackets to value name type Query struct { @@ -117,7 +116,6 @@ The `index` option will append an index number with brackets to value name values, _ := encoder.Values(&Query{Tags: []string{"foo","bar"}}) fmt.Println(values.Encode()) //(unescaped) output: "tags[0]=foo&tags[1]=bar" - All nested structs are encoded including the parent value name with brackets for scoping. type User struct { @@ -138,6 +136,16 @@ All nested structs are encoded including the parent value name with brackets for values, _ := encoder.Values(querys) fmt.Println(values.Encode()) //(unescaped) output: "user[from]=1601623397728&user[verified]=true" +Add the `dot` option to a nested struct field to scope its children with dot +notation instead of brackets. The option applies only to the field it is +declared on; nest it again on a deeper struct field to keep using dots. + + type Query struct { + User User `qs:"user,dot"` + } + + values, _ := encoder.Values(querys) + fmt.Println(values.Encode()) //(unescaped) output: "user.from=1601623397728&user.verified=true" Custom type Implement `EncodeParam` to encode itself into query param. @@ -177,10 +185,9 @@ Implement `IsZero` to check whether an object is zero to determine whether it sh } fmt.Println(values.Encode()) //(unescaped) output: "user=sonhuynh" - Limitation - - `interface`, `[]interface`, `map` are not supported yet - - `struct`, `slice`/`array` multi-level nesting are limited - - no decoder yet + - `interface`, `[]interface`, `map` are not supported yet + - `struct`, `slice`/`array` multi-level nesting are limited + - no decoder yet */ package qs diff --git a/encode.go b/encode.go index bcccac9..b5e3906 100644 --- a/encode.go +++ b/encode.go @@ -135,7 +135,7 @@ func (e *encoder) encodeStruct(stVal reflect.Value, values url.Values, scope []b if cachedFlds == nil { cachedFlds = make(cachedFields, 0, stTyp.NumField()) - e.structCaching(&cachedFlds, stVal, scope) + e.structCaching(&cachedFlds, nestedFormatBracket, scope, stVal) e.e.cache.Store(stTyp, cachedFlds) } @@ -196,7 +196,7 @@ func (e *encoder) encodeStruct(stVal reflect.Value, values url.Values, scope []b return nil } -func (e *encoder) structCaching(fields *cachedFields, stVal reflect.Value, scope []byte) { +func (e *encoder) structCaching(fields *cachedFields, notation nestedFormat, scope []byte, stVal reflect.Value) { structTyp := getType(stVal) @@ -217,10 +217,18 @@ func (e *encoder) structCaching(fields *cachedFields, stVal reflect.Value, scope if string(scope) != "" { scopedName := strings.Builder{} - scopedName.Write(scope) - scopedName.WriteRune('[') - scopedName.Write(e.tags[0]) - scopedName.WriteRune(']') + switch notation { + case nestedFormatDot: + scopedName.Write(scope) + scopedName.WriteRune('.') + scopedName.Write(e.tags[0]) + default: + scopedName.Write(scope) + scopedName.WriteRune('[') + scopedName.Write(e.tags[0]) + scopedName.WriteRune(']') + } + e.tags[0] = e.tags[0][:0] e.tags[0] = append(e.tags[0], scopedName.String()...) } @@ -245,11 +253,13 @@ func (e *encoder) structCaching(fields *cachedFields, stVal reflect.Value, scope // Clear and set new scope e.scope = e.scope[:0] e.scope = append(e.scope, e.tags[0]...) + // How this struct's children should be scoped under its name + childNotation := nestedFormatFromOptions(e.tags[1:]) // New embed field field := newEmbedField(fieldVal.NumField(), e.tags[0], e.tags[1:]) *fields = append(*fields, field) // Recursive - e.structCaching(&field.cachedFields, fieldVal, e.scope) + e.structCaching(&field.cachedFields, childNotation, e.scope, fieldVal) case reflect.Slice, reflect.Array: //Slice element type elemType := fieldTyp.Elem() @@ -277,6 +287,15 @@ func (e *encoder) structCaching(fields *cachedFields, stVal reflect.Value, scope } } +func nestedFormatFromOptions(tagOptions [][]byte) nestedFormat { + for _, tagOption := range tagOptions { + if string(tagOption) == "dot" { + return nestedFormatDot + } + } + return nestedFormatBracket +} + func (e *encoder) getTagNameAndOpts(f reflect.StructField) { // Get tag by alias tag := f.Tag.Get(e.e.tagAlias) diff --git a/encode_field.go b/encode_field.go index dc38bd7..55183ed 100644 --- a/encode_field.go +++ b/encode_field.go @@ -24,6 +24,13 @@ const ( arrayFormatIndex ) +type nestedFormat uint8 + +const ( + nestedFormatBracket nestedFormat = iota // user[from] + nestedFormatDot // user.from +) + // other fields implement baseField type baseField struct { name string @@ -237,7 +244,7 @@ func (e *encoder) newListField(elemTyp reflect.Type, tagName []byte, tagOptions } if field, ok := listField.cachedField.(*embedField); ok { - e.structCaching(&field.cachedFields, reflect.Zero(elemTyp), nil) + e.structCaching(&field.cachedFields, nestedFormatBracket, nil, reflect.Zero(elemTyp)) } return listField @@ -307,7 +314,6 @@ func newMapField(keyType reflect.Type, valueType reflect.Type, tagName []byte, t return field } -// type boolField struct { *baseField useInt bool diff --git a/encode_test.go b/encode_test.go index 7acaec4..7db9165 100644 --- a/encode_test.go +++ b/encode_test.go @@ -200,7 +200,7 @@ func TestGetTag3(t *testing.T) { } func TestEncodeInvalidValue(t *testing.T) { - //t.Parallel() + t.Parallel() var ptr *string @@ -223,7 +223,9 @@ func TestEncodeInvalidValue(t *testing.T) { } for _, testCase := range testCases { + testCase := testCase t.Run(testCase.name, func(t *testing.T) { + t.Parallel() encoder := NewEncoder() _, err := encoder.Values(testCase.input) if err == nil { @@ -897,6 +899,67 @@ func TestNestedStruct(t *testing.T) { } } +func TestNestedStructDotNotation(t *testing.T) { + t.Parallel() + encoder := NewEncoder() + + tm := time.Unix(600, 0) + + type Geo struct { + Lat string `qs:"lat"` + } + + type Addr struct { + City string `qs:"city"` + } + + type User struct { + Verified bool `qs:"verified"` + From time.Time `qs:"from,second"` + Name *string `qs:"name,omitempty"` + Addr Addr `qs:"addr,dot"` // dot cascades only because declared again + Geo Geo `qs:"geo"` // no dot -> brackets even under a dot parent + } + + s := struct { + User User `qs:"user,dot"` + UserPtr *User `qs:"user_ptr,dot"` + }{ + User: User{ + Verified: true, + From: tm, + Addr: Addr{City: "hcm"}, + Geo: Geo{Lat: "10.5"}, + }, + UserPtr: &User{ + Verified: false, + From: tm, + Addr: Addr{City: "hn"}, + Geo: Geo{Lat: "21.0"}, + }, + } + + values, err := encoder.Values(&s) + if err != nil { + t.Errorf("expected no error but got %v", err) + t.FailNow() + } + expected := url.Values{ + "user.verified": []string{"true"}, + "user.from": []string{"600"}, + "user.addr.city": []string{"hcm"}, + "user.geo[lat]": []string{"10.5"}, + "user_ptr.verified": []string{"false"}, + "user_ptr.from": []string{"600"}, + "user_ptr.addr.city": []string{"hn"}, + "user_ptr.geo[lat]": []string{"21.0"}, + } + if !reflect.DeepEqual(expected, values) { + t.Errorf("expected %v, got %v", expected, values) + t.FailNow() + } +} + func TestEncodeInterface(t *testing.T) { t.Parallel() encoder := NewEncoder() diff --git a/example/embed/embed.go b/example/embed/embed.go index d5ea89f..11eb49e 100644 --- a/example/embed/embed.go +++ b/example/embed/embed.go @@ -2,17 +2,21 @@ package main import ( "fmt" - "github.com/sonh/qs" "time" + + "github.com/sonh/qs" ) type User struct { - Verified bool `qs:"verified"` - From time.Time `qs:"from,millis"` + Verified bool `qs:"verified"` + From time.Time `qs:"from,millis"` + Roles map[string]int `qs:"roles"` } type Query struct { - User User `qs:"user"` + // The "dot" option encodes the nested struct using dot-notation keys + // (user.verified) instead of the default bracket scoping (user[verified]). + User User `qs:"user,dot"` } func main() { @@ -20,6 +24,10 @@ func main() { User: User{ Verified: true, From: time.Now(), + Roles: map[string]int{ + "admin": 1, + "member": 2, + }, }, } @@ -30,5 +38,7 @@ func main() { fmt.Println("failed") return } + // (unescaped) output: + // user.from=1601623397728&user.roles[admin]=1&user.roles[member]=2&user.verified=true fmt.Println(values.Encode()) }