From 755657806f5413da026e33911c88d346fc80fbaf Mon Sep 17 00:00:00 2001 From: Allenzhli <406568543@qq.com> Date: Thu, 11 Nov 2021 22:59:17 +0800 Subject: [PATCH] Add YAML support (#40) --- bind_test.go | 6 ++ binding.go | 33 ++++++ common_test.go | 22 ++-- go.mod | 2 +- go.sum | 14 +-- yaml_test.go | 277 +++++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 332 insertions(+), 22 deletions(-) create mode 100755 yaml_test.go diff --git a/bind_test.go b/bind_test.go index fa823b7..e632f61 100644 --- a/bind_test.go +++ b/bind_test.go @@ -35,6 +35,12 @@ func Test_Bind(t *testing.T) { } }) + Convey("Bind YAML", func() { + for _, testCase := range yamlTestCases { + performYamlTest(t, Bind, testCase) + } + }) + Convey("Bind multipart form", func() { for _, testCase := range multipartFormTestCases { performMultipartFormTest(t, Bind, testCase) diff --git a/binding.go b/binding.go index 172c9c4..34c5589 100644 --- a/binding.go +++ b/binding.go @@ -31,6 +31,7 @@ import ( "github.com/unknwon/com" "gopkg.in/macaron.v1" + "gopkg.in/yaml.v3" ) func bind(ctx *macaron.Context, obj interface{}, ifacePtr ...interface{}) { @@ -43,6 +44,8 @@ func bind(ctx *macaron.Context, obj interface{}, ifacePtr ...interface{}) { _, _ = ctx.Invoke(MultipartForm(obj, ifacePtr...)) case strings.Contains(contentType, "json"): _, _ = ctx.Invoke(Json(obj, ifacePtr...)) + case strings.Contains(contentType, "yaml"): + _, _ = ctx.Invoke(Yaml(obj, ifacePtr...)) default: var errors Errors if contentType == "" { @@ -60,6 +63,7 @@ func bind(ctx *macaron.Context, obj interface{}, ifacePtr ...interface{}) { const ( _JSON_CONTENT_TYPE = "application/json; charset=utf-8" + _YAML_CONTENT_TYPE = "text/yaml; charset=utf-8" STATUS_UNPROCESSABLE_ENTITY = 422 ) @@ -207,10 +211,39 @@ func Json(jsonStruct interface{}, ifacePtr ...interface{}) macaron.Handler { errors.Add([]string{}, ERR_DESERIALIZATION, err.Error()) } } + if errors != nil { + ctx.Map(errors) + return + } validateAndMap(jsonStruct, ctx, errors, ifacePtr...) } } +// Yaml is middleware to deserialize a YAML payload from the request +// into the struct that is passed in. The resulting struct is then +// validated, but no error handling is actually performed here. +// An interface pointer can be added as a second argument in order +// to map the struct to a specific interface. +func Yaml(yamlStruct interface{}, ifacePtr ...interface{}) macaron.Handler { + return func(ctx *macaron.Context) { + var errors Errors + ensureNotPointer(yamlStruct) + yamlStruct := reflect.New(reflect.TypeOf(yamlStruct)) + if ctx.Req.Request.Body != nil { + defer ctx.Req.Request.Body.Close() + err := yaml.NewDecoder(ctx.Req.Request.Body).Decode(yamlStruct.Interface()) + if err != nil && err != io.EOF { + errors.Add([]string{}, ERR_DESERIALIZATION, err.Error()) + } + } + if errors != nil { + ctx.Map(errors) + return + } + validateAndMap(yamlStruct, ctx, errors, ifacePtr...) + } +} + // URL is the middleware to parse URL parameters into struct fields. func URL(obj interface{}, ifacePtr ...interface{}) macaron.Handler { return func(ctx *macaron.Context) { diff --git a/common_test.go b/common_test.go index 4e84f62..c0fa14a 100755 --- a/common_test.go +++ b/common_test.go @@ -27,14 +27,14 @@ import ( type ( // For basic test cases with a required field Post struct { - Title string `form:"title" json:"title" binding:"Required"` - Content string `form:"content" json:"content"` + Title string `form:"title" json:"title" yaml:"title" binding:"Required"` + Content string `form:"content" json:"content" yaml:"content"` } // To be used as a nested struct (with a required field) Person struct { - Name string `form:"name" json:"name" binding:"Required"` - Email string `form:"email" json:"email"` + Name string `form:"name" json:"name" yaml:"name" binding:"Required"` + Email string `form:"email" json:"email" yaml:"email"` } // For advanced test cases: multiple values, embedded @@ -42,11 +42,11 @@ type ( // and multiple file uploads BlogPost struct { Post - Id int `binding:"Required"` // JSON not specified here for test coverage - Ignored string `form:"-" json:"-"` - Ratings []int `form:"rating" json:"ratings"` - Author Person `json:"author"` - Coauthor *Person `json:"coauthor"` + Id int `binding:"Required"` // JSON and YAML not specified here for test coverage + Ignored string `form:"-" json:"-" yaml:"-"` + Ratings []int `form:"rating" json:"ratings" yaml:"ratings"` + Author Person `json:"author" yaml:"author"` + Coauthor *Person `json:"coauthor" yaml:"coauthor"` HeaderImage *multipart.FileHeader Pictures []*multipart.FileHeader `form:"picture"` unexported string `form:"unexported"` //nolint @@ -79,8 +79,8 @@ type ( } Group struct { - Name string `json:"name" binding:"Required"` - People []Person `json:"people" binding:"MinSize(1)"` + Name string `json:"name" yaml:"name" binding:"Required"` + People []Person `json:"people" yaml:"people" binding:"MinSize(1)"` } UrlForm struct { diff --git a/go.mod b/go.mod index 220cad6..9e8f404 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,6 @@ go 1.12 require ( github.com/smartystreets/goconvey v0.0.0-20190731233626-505e41936337 github.com/unknwon/com v0.0.0-20190804042917-757f69c95f3e - golang.org/x/tools v0.0.0-20190805222050-c5a2fd39b72a // indirect gopkg.in/macaron.v1 v1.3.5 + gopkg.in/yaml.v3 v3.0.0-20210105161348-2e78108cf5f8 ) diff --git a/go.sum b/go.sum index 9169de6..caa4f99 100644 --- a/go.sum +++ b/go.sum @@ -21,21 +21,15 @@ golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4 h1:HuIa8hRrWRSrqYzx1qI49N golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190804053845-51ab0e2deafa/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190802220118-1d1727260058/go.mod h1:jcCCGcm9btYwXyDqrUWc6MKQKKGJCWEQ3AfLSRIbEuI= -golang.org/x/tools v0.0.0-20190805222050-c5a2fd39b72a/go.mod h1:jcCCGcm9btYwXyDqrUWc6MKQKKGJCWEQ3AfLSRIbEuI= +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/ini.v1 v1.46.0 h1:VeDZbLYGaupuvIrsYCEOe/L/2Pcs5n7hdO1ZTjporag= gopkg.in/ini.v1 v1.46.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/macaron.v1 v1.3.4 h1:HvIscOwxhFhx3swWM/979wh2QMYyuXrNmrF9l+j3HZs= -gopkg.in/macaron.v1 v1.3.4/go.mod h1:/RoHTdC8ALpyJ3+QR36mKjwnT1F1dyYtsGM9Ate6ZFI= gopkg.in/macaron.v1 v1.3.5 h1:FUA16VFBojxzfU75KqWrV/6BPv9O2R1GnybSGRie9QQ= gopkg.in/macaron.v1 v1.3.5/go.mod h1:uMZCFccv9yr5TipIalVOyAyZQuOH3OkmXvgcWwhJuP4= +gopkg.in/yaml.v3 v3.0.0-20210105161348-2e78108cf5f8 h1:tH9C0MON9YI3/KuD+u5+tQrQQ8px0MrcJ/avzeALw7o= +gopkg.in/yaml.v3 v3.0.0-20210105161348-2e78108cf5f8/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/yaml_test.go b/yaml_test.go new file mode 100755 index 0000000..c8a574c --- /dev/null +++ b/yaml_test.go @@ -0,0 +1,277 @@ +// Copyright 2014 Martini Authors +// Copyright 2014 The Macaron Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"): you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +package binding + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + . "github.com/smartystreets/goconvey/convey" + "gopkg.in/macaron.v1" + "gopkg.in/yaml.v3" +) + +var yamlTestCases = []yamlTestCase{ + { + description: "Happy path", + shouldSucceedOnYaml: true, + payload: `title: Glorious Post Title +content: Lorem ipsum dolor sit amet`, + contentType: _YAML_CONTENT_TYPE, + expected: Post{Title: "Glorious Post Title", Content: "Lorem ipsum dolor sit amet"}, + }, + { + description: "Happy path with interface", + shouldSucceedOnYaml: true, + withInterface: true, + payload: `title: Glorious Post Title +content: Lorem ipsum dolor sit amet`, + contentType: _YAML_CONTENT_TYPE, + expected: Post{Title: "Glorious Post Title", Content: "Lorem ipsum dolor sit amet"}, + }, + { + description: "Nil payload", + shouldSucceedOnYaml: false, + payload: `-nil-`, + contentType: _YAML_CONTENT_TYPE, + expected: Post{}, + }, + { + description: "Empty payload", + shouldSucceedOnYaml: false, + payload: ``, + contentType: _YAML_CONTENT_TYPE, + expected: Post{}, + }, + { + description: "Empty content type", + shouldSucceedOnYaml: true, + shouldFailOnBind: true, + payload: `title: Glorious Post Title +content: Lorem ipsum dolor sit amet`, + contentType: ``, + expected: Post{Title: "Glorious Post Title", Content: "Lorem ipsum dolor sit amet"}, + }, + { + description: "Unsupported content type", + shouldSucceedOnYaml: true, + shouldFailOnBind: true, + payload: `title: Glorious Post Title +content: Lorem ipsum dolor sit amet`, + contentType: `BoGuS`, + expected: Post{Title: "Glorious Post Title", Content: "Lorem ipsum dolor sit amet"}, + }, + { + description: "Malformed YAML", + shouldSucceedOnYaml: false, + payload: `title`, + contentType: _YAML_CONTENT_TYPE, + expected: Post{}, + }, + { + description: "Deserialization with nested and embedded struct", + shouldSucceedOnYaml: true, + payload: ` +post: + title: Glorious Post Title +id: 1 +author: + name: Matt Holt +`, + contentType: _YAML_CONTENT_TYPE, + expected: BlogPost{Post: Post{Title: "Glorious Post Title"}, Id: 1, Author: Person{Name: "Matt Holt"}}, + }, + { + description: "Deserialization with nested and embedded struct with interface", + shouldSucceedOnYaml: true, + withInterface: true, + payload: ` +post: + title: Glorious Post Title +id: 1 +author: + name: Matt Holt +`, + contentType: _YAML_CONTENT_TYPE, + expected: BlogPost{Post: Post{Title: "Glorious Post Title"}, Id: 1, Author: Person{Name: "Matt Holt"}}, + }, + { + description: "Required nested struct field not specified", + shouldSucceedOnYaml: false, + payload: ` +post: + title: Glorious Post Title +id: 1 +author: +`, + contentType: _YAML_CONTENT_TYPE, + expected: BlogPost{Post: Post{Title: "Glorious Post Title"}, Id: 1}, + }, + { + description: "Required embedded struct field not specified", + shouldSucceedOnYaml: false, + payload: ` +id: 1 +author: + name: Matt Holt +`, + contentType: _YAML_CONTENT_TYPE, + expected: BlogPost{Id: 1, Author: Person{Name: "Matt Holt"}}, + }, + { + description: "Slice of Posts", + shouldSucceedOnYaml: true, + payload: ` +- title: First Post +- title: Second Post +`, + contentType: _YAML_CONTENT_TYPE, + expected: []Post{Post{Title: "First Post"}, Post{Title: "Second Post"}}, + }, + { + description: "Slice of structs", + shouldSucceedOnYaml: true, + payload: ` +name: group1 +people: + - name: awoods + - name: anthony +`, + contentType: _YAML_CONTENT_TYPE, + expected: Group{Name: "group1", People: []Person{Person{Name: "awoods"}, Person{Name: "anthony"}}}, + }, +} + +func Test_Yaml(t *testing.T) { + Convey("Test YAML", t, func() { + for _, testCase := range yamlTestCases { + fmt.Println(testCase.description) + data, _ := yaml.Marshal(testCase.expected) + fmt.Println(string(data)) + performYamlTest(t, Yaml, testCase) + } + }) +} + +func performYamlTest(t *testing.T, binder handlerFunc, testCase yamlTestCase) { + var payload io.Reader + httpRecorder := httptest.NewRecorder() + m := macaron.Classic() + + yamlTestHandler := func(actual interface{}, errs Errors) { + if testCase.shouldSucceedOnYaml && len(errs) > 0 { + So(len(errs), ShouldEqual, 0) + } else if !testCase.shouldSucceedOnYaml && len(errs) == 0 { + So(len(errs), ShouldNotEqual, 0) + } + So(fmt.Sprintf("%+v", actual), ShouldEqual, fmt.Sprintf("%+v", testCase.expected)) + } + + switch testCase.expected.(type) { + case []Post: + if testCase.withInterface { + m.Post(testRoute, binder([]Post{}, (*modeler)(nil)), func(actual []Post, iface modeler, errs Errors) { + + for _, a := range actual { + So(a.Title, ShouldEqual, iface.Model()) + yamlTestHandler(a, errs) + } + }) + } else { + m.Post(testRoute, binder([]Post{}), func(actual []Post, errs Errors) { + yamlTestHandler(actual, errs) + }) + } + + case Post: + if testCase.withInterface { + m.Post(testRoute, binder(Post{}, (*modeler)(nil)), func(actual Post, iface modeler, errs Errors) { + So(actual.Title, ShouldEqual, iface.Model()) + yamlTestHandler(actual, errs) + }) + } else { + m.Post(testRoute, binder(Post{}), func(actual Post, errs Errors) { + yamlTestHandler(actual, errs) + }) + } + + case BlogPost: + if testCase.withInterface { + m.Post(testRoute, binder(BlogPost{}, (*modeler)(nil)), func(actual BlogPost, iface modeler, errs Errors) { + So(actual.Title, ShouldEqual, iface.Model()) + yamlTestHandler(actual, errs) + }) + } else { + m.Post(testRoute, binder(BlogPost{}), func(actual BlogPost, errs Errors) { + yamlTestHandler(actual, errs) + }) + } + case Group: + if testCase.withInterface { + m.Post(testRoute, binder(Group{}, (*modeler)(nil)), func(actual Group, iface modeler, errs Errors) { + So(actual.Name, ShouldEqual, iface.Model()) + yamlTestHandler(actual, errs) + }) + } else { + m.Post(testRoute, binder(Group{}), func(actual Group, errs Errors) { + yamlTestHandler(actual, errs) + }) + } + } + + if testCase.payload == "-nil-" { + payload = nil + } else { + payload = strings.NewReader(testCase.payload) + } + + req, err := http.NewRequest("POST", testRoute, payload) + if err != nil { + panic(err) + } + req.Header.Set("Content-Type", testCase.contentType) + + m.ServeHTTP(httpRecorder, req) + + switch httpRecorder.Code { + case http.StatusNotFound: + panic("Routing is messed up in test fixture (got 404): check method and path") + case http.StatusInternalServerError: + panic("Something bad happened on '" + testCase.description + "'") + default: + if testCase.shouldSucceedOnYaml && + httpRecorder.Code != http.StatusOK && + !testCase.shouldFailOnBind { + So(httpRecorder.Code, ShouldEqual, http.StatusOK) + } + } +} + +type ( + yamlTestCase struct { + description string + withInterface bool + shouldSucceedOnYaml bool + shouldFailOnBind bool + payload string + contentType string + expected interface{} + } +)