From 6f1d44d12ce931d2a2faa1361cf26939beba5914 Mon Sep 17 00:00:00 2001 From: Alec Sammon Date: Sat, 4 May 2024 10:01:00 +0100 Subject: [PATCH 1/7] Add support for `omitempty` field tag to exclude empty values from queries and headers --- core/formencoder.go | 10 +++++- core/header_test.go | 52 +++++++++++++++++++-------- core/query_test.go | 88 +++++++++++++++++++++++++++------------------ 3 files changed, 99 insertions(+), 51 deletions(-) diff --git a/core/formencoder.go b/core/formencoder.go index af4f72a..788b3b9 100644 --- a/core/formencoder.go +++ b/core/formencoder.go @@ -1,12 +1,20 @@ package core -import "github.com/ggicci/httpin/internal" +import ( + "github.com/ggicci/httpin/internal" + "strings" +) type FormEncoder struct { Setter func(key string, value []string) // form value setter } func (e *FormEncoder) Execute(rtm *DirectiveRuntime) error { + tag := rtm.Resolver.Field.Tag.Get("in") + if rtm.Value.IsZero() && strings.Contains(tag, "omitempty") { + return nil + } + if rtm.IsFieldSet() { return nil // skip when already encoded by former directives } diff --git a/core/header_test.go b/core/header_test.go index a9734c6..90a8cc2 100644 --- a/core/header_test.go +++ b/core/header_test.go @@ -29,23 +29,45 @@ func TestDirectiveHeader_Decode(t *testing.T) { func TestDirectiveHeader_NewRequest(t *testing.T) { type ApiQuery struct { - ApiUid int `in:"header=x-api-uid"` - ApiToken string `in:"header=X-Api-Token"` + ApiUid int `in:"header=x-api-uid"` + ApiToken *string `in:"header=X-Api-Token,omitempty"` } - query := &ApiQuery{ - ApiUid: 91241844, - ApiToken: "some-secret-token", - } + t.Run("with all values", func(t *testing.T) { + tk := "some-secret-token" + query := &ApiQuery{ + ApiUid: 91241844, + ApiToken: &tk, + } - co, err := New(ApiQuery{}) - assert.NoError(t, err) - req, err := co.NewRequest("POST", "/api", query) - assert.NoError(t, err) + co, err := New(ApiQuery{}) + assert.NoError(t, err) + req, err := co.NewRequest("POST", "/api", query) + assert.NoError(t, err) + + expected, _ := http.NewRequest("POST", "/api", nil) + // NOTE: the key will be canonicalized + expected.Header.Set("x-api-uid", "91241844") + expected.Header.Set("X-Api-Token", "some-secret-token") + assert.Equal(t, expected, req) + }) + + t.Run("with nil value", func(t *testing.T) { + query := &ApiQuery{ + ApiUid: 91241844, + ApiToken: nil, + } + + co, err := New(ApiQuery{}) + assert.NoError(t, err) + req, err := co.NewRequest("POST", "/api", query) + assert.NoError(t, err) + + expected, _ := http.NewRequest("POST", "/api", nil) + expected.Header.Set("x-api-uid", "91241844") + assert.Equal(t, expected, req) - expected, _ := http.NewRequest("POST", "/api", nil) - // NOTE: the key will be canonicalized - expected.Header.Set("x-api-uid", "91241844") - expected.Header.Set("X-Api-Token", "some-secret-token") - assert.Equal(t, expected, req) + _, ok := req.Header["X-Api-Token"] + assert.False(t, ok) + }) } diff --git a/core/query_test.go b/core/query_test.go index fa6b787..7409dbc 100644 --- a/core/query_test.go +++ b/core/query_test.go @@ -40,43 +40,61 @@ func TestDirectiveQuery_NewRequest(t *testing.T) { NameList []string `in:"query=name_list[]"` AgeList []int `in:"query=age_list[]"` - NamePointer *string `in:"query=name_pointer"` - AgePointer *int `in:"query=age_pointer"` - } - query := &SearchQuery{ - Name: "cupcake", - Age: 12, - Enabled: true, - Price: 6.28, - NameList: []string{"apple", "banana", "cherry"}, - AgeList: []int{1, 2, 3}, - NamePointer: func() *string { - s := "pointer cupcake" - return &s - }(), - AgePointer: func() *int { - i := 19 - return &i - }(), + NamePointer *string `in:"query=name_pointer,omitempty"` + AgePointer *int `in:"query=age_pointer,omitempty"` } - co, err := New(SearchQuery{}) - assert.NoError(t, err) - req, err := co.NewRequest("GET", "/pets", query) - assert.NoError(t, err) - - expected, _ := http.NewRequest("GET", "/pets", nil) - expectedQuery := make(url.Values) - expectedQuery.Set("name", query.Name) // query.Name - expectedQuery.Set("age", "12") // query.Age - expectedQuery.Set("enabled", "true") // query.Enabled - expectedQuery.Set("price", "6.28") // query.Price - expectedQuery["name_list[]"] = query.NameList // query.NameList - expectedQuery["age_list[]"] = []string{"1", "2", "3"} // query.AgeList - expectedQuery.Set("name_pointer", *query.NamePointer) // query.NamePointer - expectedQuery.Set("age_pointer", "19") // query.PointerAge - expected.URL.RawQuery = expectedQuery.Encode() - assert.Equal(t, expected, req) + t.Run("with all values", func(t *testing.T) { + query := &SearchQuery{ + Name: "cupcake", + Age: 12, + Enabled: true, + Price: 6.28, + NameList: []string{"apple", "banana", "cherry"}, + AgeList: []int{1, 2, 3}, + NamePointer: func() *string { + s := "pointer cupcake" + return &s + }(), + AgePointer: func() *int { + i := 19 + return &i + }(), + } + + co, err := New(SearchQuery{}) + assert.NoError(t, err) + req, err := co.NewRequest("GET", "/pets", query) + assert.NoError(t, err) + + expected, _ := http.NewRequest("GET", "/pets", nil) + expectedQuery := make(url.Values) + expectedQuery.Set("name", query.Name) // query.Name + expectedQuery.Set("age", "12") // query.Age + expectedQuery.Set("enabled", "true") // query.Enabled + expectedQuery.Set("price", "6.28") // query.Price + expectedQuery["name_list[]"] = query.NameList // query.NameList + expectedQuery["age_list[]"] = []string{"1", "2", "3"} // query.AgeList + expectedQuery.Set("name_pointer", *query.NamePointer) // query.NamePointer + expectedQuery.Set("age_pointer", "19") // query.PointerAge + expected.URL.RawQuery = expectedQuery.Encode() + assert.Equal(t, expected, req) + }) + + t.Run("with nil values", func(t *testing.T) { + query := &SearchQuery{} + + co, err := New(SearchQuery{}) + assert.NoError(t, err) + req, err := co.NewRequest("GET", "/pets", query) + assert.NoError(t, err) + + assert.True(t, req.URL.Query().Has("name")) + assert.True(t, req.URL.Query().Has("age")) + + assert.False(t, req.URL.Query().Has("name_pointer")) + assert.False(t, req.URL.Query().Has("age_pointer")) + }) } type Location struct { From b5a6b73e562edac472ffbe5e178adb771662e7ca Mon Sep 17 00:00:00 2001 From: Alec Sammon Date: Sat, 4 May 2024 10:08:22 +0100 Subject: [PATCH 2/7] * add line to README --- README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 8a30349..e27822b 100644 --- a/README.md +++ b/README.md @@ -52,10 +52,11 @@ Since v0.15.0, httpin also supports creating an HTTP request (`http.Request`) fr ```go type ListUsersInput struct { - Token string `in:"query=access_token;header=x-access-token"` - Page int `in:"query=page;default=1"` - PerPage int `in:"query=per_page;default=20"` - IsMember bool `in:"query=is_member"` + Token string `in:"query=access_token;header=x-access-token"` + Page int `in:"query=page;default=1"` + PerPage int `in:"query=per_page;default=20"` + IsMember bool `in:"query=is_member"` + Search *string `in:"query=search,omitempty"` } func ListUsers(rw http.ResponseWriter, r *http.Request) { From 2d58c8ed82cf324f3d32126d25f9f6dcf683deab Mon Sep 17 00:00:00 2001 From: Alec Sammon Date: Sat, 4 May 2024 10:09:47 +0100 Subject: [PATCH 3/7] * show both pointer and non-pointer in tests --- core/header_test.go | 2 +- core/query_test.go | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/core/header_test.go b/core/header_test.go index 90a8cc2..5113782 100644 --- a/core/header_test.go +++ b/core/header_test.go @@ -52,7 +52,7 @@ func TestDirectiveHeader_NewRequest(t *testing.T) { assert.Equal(t, expected, req) }) - t.Run("with nil value", func(t *testing.T) { + t.Run("with empty value", func(t *testing.T) { query := &ApiQuery{ ApiUid: 91241844, ApiToken: nil, diff --git a/core/query_test.go b/core/query_test.go index 7409dbc..5691883 100644 --- a/core/query_test.go +++ b/core/query_test.go @@ -33,14 +33,14 @@ func TestDirectiveQuery_Decode(t *testing.T) { func TestDirectiveQuery_NewRequest(t *testing.T) { type SearchQuery struct { Name string `in:"query=name"` - Age int `in:"query=age"` + Age int `in:"query=age,omitempty"` Enabled bool `in:"query=enabled"` Price float64 `in:"query=price"` NameList []string `in:"query=name_list[]"` AgeList []int `in:"query=age_list[]"` - NamePointer *string `in:"query=name_pointer,omitempty"` + NamePointer *string `in:"query=name_pointer"` AgePointer *int `in:"query=age_pointer,omitempty"` } @@ -81,7 +81,7 @@ func TestDirectiveQuery_NewRequest(t *testing.T) { assert.Equal(t, expected, req) }) - t.Run("with nil values", func(t *testing.T) { + t.Run("with empty values", func(t *testing.T) { query := &SearchQuery{} co, err := New(SearchQuery{}) @@ -90,9 +90,9 @@ func TestDirectiveQuery_NewRequest(t *testing.T) { assert.NoError(t, err) assert.True(t, req.URL.Query().Has("name")) - assert.True(t, req.URL.Query().Has("age")) + assert.False(t, req.URL.Query().Has("age")) - assert.False(t, req.URL.Query().Has("name_pointer")) + assert.True(t, req.URL.Query().Has("name_pointer")) assert.False(t, req.URL.Query().Has("age_pointer")) }) } From e776224900636ba22611262c474e46c44a814b90 Mon Sep 17 00:00:00 2001 From: Alec Sammon Date: Sat, 4 May 2024 12:29:52 +0100 Subject: [PATCH 4/7] * additional test --- core/header_test.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/core/header_test.go b/core/header_test.go index 5113782..639db7f 100644 --- a/core/header_test.go +++ b/core/header_test.go @@ -29,7 +29,7 @@ func TestDirectiveHeader_Decode(t *testing.T) { func TestDirectiveHeader_NewRequest(t *testing.T) { type ApiQuery struct { - ApiUid int `in:"header=x-api-uid"` + ApiUid int `in:"header=x-api-uid,omitempty"` ApiToken *string `in:"header=X-Api-Token,omitempty"` } @@ -54,7 +54,7 @@ func TestDirectiveHeader_NewRequest(t *testing.T) { t.Run("with empty value", func(t *testing.T) { query := &ApiQuery{ - ApiUid: 91241844, + ApiUid: 0, ApiToken: nil, } @@ -64,10 +64,12 @@ func TestDirectiveHeader_NewRequest(t *testing.T) { assert.NoError(t, err) expected, _ := http.NewRequest("POST", "/api", nil) - expected.Header.Set("x-api-uid", "91241844") assert.Equal(t, expected, req) - _, ok := req.Header["X-Api-Token"] + _, ok := req.Header["X-Api-Uid"] + assert.False(t, ok) + + _, ok = req.Header["X-Api-Token"] assert.False(t, ok) }) } From ac0357ca6c2835c2b6fa3ac7f680efc063e696ff Mon Sep 17 00:00:00 2001 From: Alec Sammon Date: Sat, 18 May 2024 10:00:42 +0100 Subject: [PATCH 5/7] * use rtm.Directive.Argv --- core/formencoder.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/core/formencoder.go b/core/formencoder.go index 788b3b9..76f77be 100644 --- a/core/formencoder.go +++ b/core/formencoder.go @@ -2,7 +2,7 @@ package core import ( "github.com/ggicci/httpin/internal" - "strings" + "slices" ) type FormEncoder struct { @@ -10,8 +10,7 @@ type FormEncoder struct { } func (e *FormEncoder) Execute(rtm *DirectiveRuntime) error { - tag := rtm.Resolver.Field.Tag.Get("in") - if rtm.Value.IsZero() && strings.Contains(tag, "omitempty") { + if rtm.Value.IsZero() && slices.Contains(rtm.Directive.Argv, "omitempty") { return nil } From c7707e1303b1f7a15f91b236ebba2fd30864f3a4 Mon Sep 17 00:00:00 2001 From: Alec Sammon Date: Sat, 18 May 2024 10:06:36 +0100 Subject: [PATCH 6/7] * use directive --- README.md | 2 +- core/directive.go | 1 + core/formencoder.go | 9 ++++++--- core/header_test.go | 4 ++-- core/omitempty.go | 17 +++++++++++++++++ core/query_test.go | 4 ++-- 6 files changed, 29 insertions(+), 8 deletions(-) create mode 100644 core/omitempty.go diff --git a/README.md b/README.md index e27822b..382ef41 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ type ListUsersInput struct { Page int `in:"query=page;default=1"` PerPage int `in:"query=per_page;default=20"` IsMember bool `in:"query=is_member"` - Search *string `in:"query=search,omitempty"` + Search *string `in:"query=search;omitempty"` } func ListUsers(rw http.ResponseWriter, r *http.Request) { diff --git a/core/directive.go b/core/directive.go index fa82fc3..9c7e808 100644 --- a/core/directive.go +++ b/core/directive.go @@ -17,6 +17,7 @@ func init() { RegisterDirective("default", &DirectiveDefault{}) RegisterDirective("nonzero", &DirectiveNonzero{}) registerDirective("path", defaultPathDirective) + registerDirective("omitempty", &DirectiveOmitEmpty{}) // decoder is a special executor which does nothing, but is an indicator of // overriding the decoder for a specific field. diff --git a/core/formencoder.go b/core/formencoder.go index 76f77be..7757ba0 100644 --- a/core/formencoder.go +++ b/core/formencoder.go @@ -2,7 +2,6 @@ package core import ( "github.com/ggicci/httpin/internal" - "slices" ) type FormEncoder struct { @@ -10,8 +9,12 @@ type FormEncoder struct { } func (e *FormEncoder) Execute(rtm *DirectiveRuntime) error { - if rtm.Value.IsZero() && slices.Contains(rtm.Directive.Argv, "omitempty") { - return nil + if rtm.Value.IsZero() { + for _, d := range rtm.Resolver.Directives { + if d.Name == "omitempty" { + return nil + } + } } if rtm.IsFieldSet() { diff --git a/core/header_test.go b/core/header_test.go index 639db7f..9def1cb 100644 --- a/core/header_test.go +++ b/core/header_test.go @@ -29,8 +29,8 @@ func TestDirectiveHeader_Decode(t *testing.T) { func TestDirectiveHeader_NewRequest(t *testing.T) { type ApiQuery struct { - ApiUid int `in:"header=x-api-uid,omitempty"` - ApiToken *string `in:"header=X-Api-Token,omitempty"` + ApiUid int `in:"header=x-api-uid;omitempty"` + ApiToken *string `in:"header=X-Api-Token;omitempty"` } t.Run("with all values", func(t *testing.T) { diff --git a/core/omitempty.go b/core/omitempty.go new file mode 100644 index 0000000..f7e002a --- /dev/null +++ b/core/omitempty.go @@ -0,0 +1,17 @@ +// directive: "omitempty" +// https://ggicci.github.io/httpin/directives/omitempty + +package core + +// DirectiveOmitEmpty is used with the DirectiveQuery, DirectiveForm, and DirectiveHeader to indicate that the field +// should be omitted when the value is empty. +// It does not have any affect when used by itself +type DirectiveOmitEmpty struct{} + +func (*DirectiveOmitEmpty) Decode(_ *DirectiveRuntime) error { + return nil +} + +func (*DirectiveOmitEmpty) Encode(_ *DirectiveRuntime) error { + return nil +} diff --git a/core/query_test.go b/core/query_test.go index 5691883..f2598b0 100644 --- a/core/query_test.go +++ b/core/query_test.go @@ -33,7 +33,7 @@ func TestDirectiveQuery_Decode(t *testing.T) { func TestDirectiveQuery_NewRequest(t *testing.T) { type SearchQuery struct { Name string `in:"query=name"` - Age int `in:"query=age,omitempty"` + Age int `in:"query=age;omitempty"` Enabled bool `in:"query=enabled"` Price float64 `in:"query=price"` @@ -41,7 +41,7 @@ func TestDirectiveQuery_NewRequest(t *testing.T) { AgeList []int `in:"query=age_list[]"` NamePointer *string `in:"query=name_pointer"` - AgePointer *int `in:"query=age_pointer,omitempty"` + AgePointer *int `in:"query=age_pointer;omitempty"` } t.Run("with all values", func(t *testing.T) { From a7e1cb0fd5c17ec132bb2fe786e2559a0526f1cd Mon Sep 17 00:00:00 2001 From: Alec Sammon Date: Sat, 18 May 2024 21:39:39 +0100 Subject: [PATCH 7/7] * simplify code --- core/formencoder.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/core/formencoder.go b/core/formencoder.go index 7757ba0..a143dfc 100644 --- a/core/formencoder.go +++ b/core/formencoder.go @@ -10,10 +10,8 @@ type FormEncoder struct { func (e *FormEncoder) Execute(rtm *DirectiveRuntime) error { if rtm.Value.IsZero() { - for _, d := range rtm.Resolver.Directives { - if d.Name == "omitempty" { - return nil - } + if rtm.Resolver.GetDirective("omitempty") != nil { + return nil } }