From e8483ce002a4ee12da0c99103def590ac3bf4aee Mon Sep 17 00:00:00 2001 From: GitstartHQ Date: Mon, 4 Apr 2022 08:28:49 +0000 Subject: [PATCH] [GIN-002] Add AVRO binding --- README.md | 26 ++++++++++++---- binding/avro.go | 58 ++++++++++++++++++++++++++++++++++++ binding/avro_test.go | 32 ++++++++++++++++++++ binding/binding.go | 4 +++ binding/binding_nomsgpack.go | 4 +++ binding/binding_test.go | 32 +++++++++++++++++++- context.go | 5 ++++ context_test.go | 16 ++++++++++ go.mod | 5 ++-- go.sum | 11 +++++-- render/avro.go | 37 +++++++++++++++++++++++ render/render.go | 3 +- 12 files changed, 221 insertions(+), 12 deletions(-) create mode 100644 binding/avro.go create mode 100644 binding/avro_test.go create mode 100644 render/avro.go diff --git a/README.md b/README.md index 4aa638d6..eadcee27 100644 --- a/README.md +++ b/README.md @@ -658,7 +658,7 @@ func main() { ### Model binding and validation -To bind a request body into a type, use model binding. We currently support binding of JSON, XML, YAML and standard form values (foo=bar&boo=baz). +To bind a request body into a type, use model binding. We currently support binding of JSON, XML, YAML, AVRO and standard form values (foo=bar&boo=baz). Gin uses [**go-playground/validator/v10**](https://github.com/go-playground/validator) for validation. Check the full docs on tags usage [here](https://godoc.org/github.com/go-playground/validator#hdr-Baked_In_Validators_and_Tags). @@ -666,10 +666,10 @@ Note that you need to set the corresponding binding tag on all fields you want t Also, Gin provides two sets of methods for binding: - **Type** - Must bind - - **Methods** - `Bind`, `BindJSON`, `BindXML`, `BindQuery`, `BindYAML`, `BindHeader` + - **Methods** - `Bind`, `BindJSON`, `BindXML`, `BindQuery`, `BindYAML`, `BindHeader`, `BindAVRO` - **Behavior** - These methods use `MustBindWith` under the hood. If there is a binding error, the request is aborted with `c.AbortWithError(400, err).SetType(ErrorTypeBind)`. This sets the response status code to 400 and the `Content-Type` header is set to `text/plain; charset=utf-8`. Note that if you try to set the response code after this, it will result in a warning `[GIN-debug] [WARNING] Headers were already written. Wanted to override status code 400 with 422`. If you wish to have greater control over the behavior, consider using the `ShouldBind` equivalent method. - **Type** - Should bind - - **Methods** - `ShouldBind`, `ShouldBindJSON`, `ShouldBindXML`, `ShouldBindQuery`, `ShouldBindYAML`, `ShouldBindHeader` + - **Methods** - `ShouldBind`, `ShouldBindJSON`, `ShouldBindXML`, `ShouldBindQuery`, `ShouldBindYAML`, `ShouldBindHeader`,`ShouldBindAVRO` - **Behavior** - These methods use `ShouldBindWith` under the hood. If there is a binding error, the error is returned and it is the developer's responsibility to handle the request and error appropriately. When using the Bind-method, Gin tries to infer the binder depending on the Content-Type header. If you are sure what you are binding, you can use `MustBindWith` or `ShouldBindWith`. @@ -679,8 +679,8 @@ You can also specify that specific fields are required. If a field is decorated ```go // Binding from JSON type Login struct { - User string `form:"user" json:"user" xml:"user" binding:"required"` - Password string `form:"password" json:"password" xml:"password" binding:"required"` + User string `form:"user" json:"user" xml:"user" avro:"user" binding:"required"` + Password string `form:"password" json:"password" xml:"password" avro:"avro" binding:"required"` } func main() { @@ -740,6 +740,22 @@ func main() { c.JSON(http.StatusOK, gin.H{"status": "you are logged in"}) }) + // Example for binding AVRO ({"user": "manu", "password": "123"}) + router.POST("/loginAVRO", func(c *gin.Context) { + var avro Login + if err := c.ShouldBindAVRO(&avro); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if json.User != "manu" || json.Password != "123" { + c.JSON(http.StatusUnauthorized, gin.H{"status": "unauthorized"}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "you are logged in"}) + }) + // Listen and serve on 0.0.0.0:8080 router.Run(":8080") } diff --git a/binding/avro.go b/binding/avro.go new file mode 100644 index 00000000..93eebba5 --- /dev/null +++ b/binding/avro.go @@ -0,0 +1,58 @@ +// Copyright 2022 Gin Core Team. All rights reserved. +// Use of this source code is governed by a MIT style +// license that can be found in the LICENSE file. + +package binding + +import ( + "bytes" + "io" + "io/ioutil" + "net/http" + + "github.com/gin-gonic/gin/internal/json" + "github.com/hamba/avro" +) + +type avroBinding struct { + s string +} + +func (avroBinding) Name() string { + return "avro" +} + +func (r avroBinding) Bind(req *http.Request, obj any) error { + return decodeAvro(req.Body, r.s, obj) +} + +func (avroBinding) BindBody(body []byte, s string, obj any) error { + return decodeAvro(bytes.NewReader(body), s, obj) +} + +func decodeAvro(r io.Reader, s string, obj any) error { + body, err := ioutil.ReadAll(r) + if err != nil { + return err + } + err = json.Unmarshal(body, &obj) + if err != nil { + return err + } + schema, err := avro.Parse(s) + if err != nil { + return err + } + data, err := avro.Marshal(schema, obj) + if err != nil { + return err + } + decoder, err := avro.NewDecoder(s, bytes.NewReader(data)) + if err != nil { + return err + } + if err := decoder.Decode(obj); err != nil { + return err + } + return validate(obj) +} diff --git a/binding/avro_test.go b/binding/avro_test.go new file mode 100644 index 00000000..29c25b70 --- /dev/null +++ b/binding/avro_test.go @@ -0,0 +1,32 @@ +// Copyright 2019 Gin Core Team. All rights reserved. +// Use of this source code is governed by a MIT style +// license that can be found in the LICENSE file. + +package binding + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAvroBindingBindBody(t *testing.T) { + var s struct { + A int64 `avro:"a"` + B string `avro:"b"` + } + schema := `{ + "type": "record", + "name": "test", + "fields" : [ + {"name": "a", "type": "long"}, + {"name": "b", "type": "string"} + ] + }` + avroBody := `{"a": 27, "b": "foo"}` + + err := avroBinding{}.BindBody([]byte(avroBody), schema, &s) + require.NoError(t, err) + assert.Equal(t, "foo", s.B) +} diff --git a/binding/binding.go b/binding/binding.go index 703a1cf8..d2953597 100644 --- a/binding/binding.go +++ b/binding/binding.go @@ -22,6 +22,7 @@ const ( MIMEMSGPACK = "application/x-msgpack" MIMEMSGPACK2 = "application/msgpack" MIMEYAML = "application/x-yaml" + MIMEAVRO = "application/x-avro" ) // Binding describes the interface which needs to be implemented for binding the @@ -83,6 +84,7 @@ var ( YAML = yamlBinding{} Uri = uriBinding{} Header = headerBinding{} + AVRO = avroBinding{} ) // Default returns the appropriate Binding instance based on the HTTP method @@ -105,6 +107,8 @@ func Default(method, contentType string) Binding { return YAML case MIMEMultipartPOSTForm: return FormMultipart + case MIMEAVRO: + return AVRO default: // case MIMEPOSTForm: return Form } diff --git a/binding/binding_nomsgpack.go b/binding/binding_nomsgpack.go index b3818549..a5717df6 100644 --- a/binding/binding_nomsgpack.go +++ b/binding/binding_nomsgpack.go @@ -20,6 +20,7 @@ const ( MIMEMultipartPOSTForm = "multipart/form-data" MIMEPROTOBUF = "application/x-protobuf" MIMEYAML = "application/x-yaml" + MIMEAVRO = "application/x-avro" ) // Binding describes the interface which needs to be implemented for binding the @@ -79,6 +80,7 @@ var ( YAML = yamlBinding{} Uri = uriBinding{} Header = headerBinding{} + AVRO = avroBinding{} ) // Default returns the appropriate Binding instance based on the HTTP method @@ -99,6 +101,8 @@ func Default(method, contentType string) Binding { return YAML case MIMEMultipartPOSTForm: return FormMultipart + case MIMEAVRO: + return AVRO default: // case MIMEPOSTForm: return Form } diff --git a/binding/binding_test.go b/binding/binding_test.go index b1edbf5a..b3824b80 100644 --- a/binding/binding_test.go +++ b/binding/binding_test.go @@ -35,7 +35,7 @@ type QueryTest struct { } type FooStruct struct { - Foo string `msgpack:"foo" json:"foo" form:"foo" xml:"foo" binding:"required,max=32"` + Foo string `msgpack:"foo" json:"foo" form:"foo" avro:"foo" xml:"foo" binding:"required,max=32"` } type FooBarStruct struct { @@ -144,6 +144,11 @@ type FooStructForMapPtrType struct { PtrBar *map[string]any `form:"ptr_bar"` } +type FooStructForAvro struct { + A int64 `avro:"a"` + B string `avro:"b"` +} + func TestBindingDefault(t *testing.T) { assert.Equal(t, Form, Default("GET", "")) assert.Equal(t, Form, Default("GET", MIMEJSON)) @@ -165,6 +170,9 @@ func TestBindingDefault(t *testing.T) { assert.Equal(t, YAML, Default("POST", MIMEYAML)) assert.Equal(t, YAML, Default("PUT", MIMEYAML)) + + assert.Equal(t, AVRO, Default("POST", MIMEAVRO)) + assert.Equal(t, AVRO, Default("PUT", MIMEAVRO)) } func TestBindingJSONNilBody(t *testing.T) { @@ -461,6 +469,14 @@ func TestBindingYAML(t *testing.T) { `foo: bar`, `bar: foo`) } +func TestBindingAVRO(t *testing.T) { + s := `{"type": "record","name": "test","fields" : [{"name": "a", "type": "long"},{"name": "b", "type": "string"}]}` + testBodyBindingAvro(t, + AVRO, "avro", s, + "/", "/", + `{"a": 27, "b": "foo"}`, `{foo:bar}`) +} + func TestBindingYAMLStringMap(t *testing.T) { // YAML is a superset of JSON, so the test below is JSON (to avoid newlines) testBodyBindingStringMap(t, YAML, @@ -1321,6 +1337,20 @@ func testProtoBodyBinding(t *testing.T, b Binding, name, path, badPath, body, ba err = ProtoBuf.Bind(req, &obj) assert.Error(t, err) } +func testBodyBindingAvro(t *testing.T, b Binding, name, schema, path, badPath, body, badBody string) { + assert.Equal(t, name, b.Name()) + + req := requestWithBody("POST", path, body) + obj := FooStructForAvro{} + err := avroBinding{s: schema}.Bind(req, &obj) + assert.NoError(t, err) + assert.Equal(t, "foo", obj.B) + + obj = FooStructForAvro{} + req = requestWithBody("POST", badPath, badBody) + err = avroBinding{s: schema}.Bind(req, &obj) + assert.Error(t, err) +} type hook struct{} diff --git a/context.go b/context.go index faa48133..98c8e4ad 100644 --- a/context.go +++ b/context.go @@ -690,6 +690,11 @@ func (c *Context) ShouldBindYAML(obj any) error { return c.ShouldBindWith(obj, binding.YAML) } +// ShouldBindAVRO is a shortcut for c.ShouldBindWith(obj, binding.AVRO). +func (c *Context) ShouldBindAVRO(obj any) error { + return c.ShouldBindWith(obj, binding.AVRO) +} + // ShouldBindHeader is a shortcut for c.ShouldBindWith(obj, binding.Header). func (c *Context) ShouldBindHeader(obj any) error { return c.ShouldBindWith(obj, binding.Header) diff --git a/context_test.go b/context_test.go index fb46e679..3fe5a71b 100644 --- a/context_test.go +++ b/context_test.go @@ -1773,6 +1773,22 @@ func TestContextShouldBindWithYAML(t *testing.T) { assert.Equal(t, 0, w.Body.Len()) } +func TestContextShouldBindWithAVRO(t *testing.T) { + c, _ := CreateTestContext(httptest.NewRecorder()) + c.Request, _ = http.NewRequest("POST", "/", bytes.NewBufferString("{\"foo\":\"bar\", \"bar\":\"foo\"}")) + c.Request.Header.Add("Content-Type", MIMEJSON) + + var obj struct { + Foo string `avro:"foo"` + Bar string `avro:"bar"` + } + assert.NoError(t, c.ShouldBind(&obj)) + assert.Equal(t, "foo", obj.Bar) + assert.Equal(t, "bar", obj.Foo) + assert.Empty(t, c.Errors) +} + + func TestContextBadAutoShouldBind(t *testing.T) { w := httptest.NewRecorder() c, _ := CreateTestContext(w) diff --git a/go.mod b/go.mod index bfb462b8..db298534 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,8 @@ go 1.18 require ( github.com/gin-contrib/sse v0.1.0 github.com/go-playground/validator/v10 v10.10.0 - github.com/goccy/go-json v0.9.6 + github.com/goccy/go-json v0.9.5 + github.com/hamba/avro v1.6.6 github.com/json-iterator/go v1.1.12 github.com/mattn/go-isatty v0.0.14 github.com/stretchr/testify v1.7.1 @@ -20,7 +21,7 @@ require ( github.com/go-playground/locales v0.14.0 // indirect github.com/go-playground/universal-translator v0.18.0 // indirect github.com/leodido/go-urn v1.2.1 // indirect - github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97 // indirect diff --git a/go.sum b/go.sum index 983b50a4..aeb061c4 100644 --- a/go.sum +++ b/go.sum @@ -12,12 +12,15 @@ github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/j github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA= github.com/go-playground/validator/v10 v10.10.0 h1:I7mrTYv78z8k8VXa/qJlOlEXn/nBh+BF8dHX5nt/dr0= github.com/go-playground/validator/v10 v10.10.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos= -github.com/goccy/go-json v0.9.6 h1:5/4CtRQdtsX0sal8fdVhTaiMN01Ri8BExZZ8iRmHQ6E= -github.com/goccy/go-json v0.9.6/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-json v0.9.5 h1:ooSMW526ZjK+EaL5elrSyN2EzIfi/3V0m4+HJEDYLik= +github.com/goccy/go-json v0.9.5/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/hamba/avro v1.6.6 h1:iIwyk5GVE0YuC+y4AYxoalo2dsNQjpNKQByW3pvONA8= +github.com/hamba/avro v1.6.6/go.mod h1:iKbXifVeT1gOHU+Eqe8wWziE745Z+Aa/6sbJnWeSW5A= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -32,8 +35,9 @@ github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w= github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= @@ -48,6 +52,7 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/ugorji/go v1.2.7 h1:qYhyWUUd6WbiM+C6JZAUkIJt/1WrjzNHY9+KCIjVqTo= github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M= github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0= github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY= diff --git a/render/avro.go b/render/avro.go new file mode 100644 index 00000000..c4f81ea1 --- /dev/null +++ b/render/avro.go @@ -0,0 +1,37 @@ +// Copyright 2014 Manu Martinez-Almeida. All rights reserved. +// Use of this source code is governed by a MIT style +// license that can be found in the LICENSE file. + +package render + +import ( + "net/http" + + "github.com/hamba/avro" +) + +// AVRO contains the given interface object. +type AVRO struct { + Data interface{} + schema avro.Schema +} + +var avroContentType = []string{"application/x-avro; charset=utf-8"} + +// Render (AVRO) marshals the given interface object and writes data with custom ContentType. +func (r AVRO) Render(w http.ResponseWriter) error { + r.WriteContentType(w) + + bytes, err := avro.Marshal(r.schema, r.Data) + if err != nil { + return err + } + + _, err = w.Write(bytes) + return err +} + +// WriteContentType (AVRO) writes AVRO ContentType for response. +func (r AVRO) WriteContentType(w http.ResponseWriter) { + writeContentType(w, avroContentType) +} diff --git a/render/render.go b/render/render.go index bcd568bf..b2cb29c3 100644 --- a/render/render.go +++ b/render/render.go @@ -6,7 +6,7 @@ package render import "net/http" -// Render interface is to be implemented by JSON, XML, HTML, YAML and so on. +// Render interface is to be implemented by JSON, XML, HTML, YAML, AVRO and so on. type Render interface { // Render writes data with custom ContentType. Render(http.ResponseWriter) error @@ -30,6 +30,7 @@ var ( _ Render = Reader{} _ Render = AsciiJSON{} _ Render = ProtoBuf{} + _ Render = AVRO{} ) func writeContentType(w http.ResponseWriter, value []string) {