diff --git a/app.go b/app.go index e1e607e6fa..71905f8874 100644 --- a/app.go +++ b/app.go @@ -23,9 +23,9 @@ import ( "sync" "time" + "github.com/fxamacker/cbor/v2" "github.com/gofiber/fiber/v3/log" "github.com/gofiber/utils/v2" - "github.com/valyala/fasthttp" ) @@ -318,6 +318,20 @@ type Config struct { //nolint:govet // Aligning the struct fields is not necessa // Default: json.Unmarshal JSONDecoder utils.JSONUnmarshal `json:"-"` + // When set by an external client of Fiber it will use the provided implementation of a + // CBORMarshal + // + // Allowing for flexibility in using another cbor library for encoding + // Default: cbor.Marshal + CBOREncoder utils.CBORMarshal `json:"-"` + + // When set by an external client of Fiber it will use the provided implementation of a + // CBORUnmarshal + // + // Allowing for flexibility in using another cbor library for decoding + // Default: cbor.Unmarshal + CBORDecoder utils.CBORUnmarshal `json:"-"` + // XMLEncoder set by an external client of Fiber it will use the provided implementation of a // XMLMarshal // @@ -535,6 +549,12 @@ func New(config ...Config) *App { if app.config.JSONDecoder == nil { app.config.JSONDecoder = json.Unmarshal } + if app.config.CBOREncoder == nil { + app.config.CBOREncoder = cbor.Marshal + } + if app.config.CBORDecoder == nil { + app.config.CBORDecoder = cbor.Unmarshal + } if app.config.XMLEncoder == nil { app.config.XMLEncoder = xml.Marshal } diff --git a/bind.go b/bind.go index eff595cd72..2cd6e73a69 100644 --- a/bind.go +++ b/bind.go @@ -120,6 +120,13 @@ func (b *Bind) JSON(out any) error { return b.validateStruct(out) } +func (b *Bind) CBOR(out any) error { + if err := b.returnErr(binder.CBORBinder.Bind(b.ctx.Body(), b.ctx.App().Config().CBORDecoder, out)); err != nil { + return err + } + return b.validateStruct(out) +} + // XML binds the body string into the struct. func (b *Bind) XML(out any) error { if err := b.returnErr(binder.XMLBinder.Bind(b.ctx.Body(), out)); err != nil { @@ -182,6 +189,8 @@ func (b *Bind) Body(out any) error { return b.JSON(out) case MIMETextXML, MIMEApplicationXML: return b.XML(out) + case MIMEApplicationCBOR: + return b.CBOR(out) case MIMEApplicationForm: return b.Form(out) case MIMEMultipartForm: diff --git a/bind_test.go b/bind_test.go index aa00e191ca..f13238918d 100644 --- a/bind_test.go +++ b/bind_test.go @@ -12,6 +12,7 @@ import ( "testing" "time" + "github.com/fxamacker/cbor/v2" "github.com/gofiber/fiber/v3/binder" "github.com/stretchr/testify/require" "github.com/valyala/fasthttp" @@ -913,11 +914,11 @@ func Test_Bind_Body(t *testing.T) { testCompressedBody(t, compressedBody, "zstd") }) - testDecodeParser := func(t *testing.T, contentType, body string) { + testDecodeParser := func(t *testing.T, contentType string, body []byte) { t.Helper() c := app.AcquireCtx(&fasthttp.RequestCtx{}) c.Request().Header.SetContentType(contentType) - c.Request().SetBody([]byte(body)) + c.Request().SetBody(body) c.Request().Header.SetContentLength(len(body)) d := new(Demo) require.NoError(t, c.Bind().Body(d)) @@ -925,19 +926,26 @@ func Test_Bind_Body(t *testing.T) { } t.Run("JSON", func(t *testing.T) { - testDecodeParser(t, MIMEApplicationJSON, `{"name":"john"}`) + testDecodeParser(t, MIMEApplicationJSON, []byte(`{"name":"john"}`)) + }) + t.Run("CBOR", func(t *testing.T) { + enc, err := cbor.Marshal(&Demo{Name: "john"}) + if err != nil { + t.Error(err) + } + testDecodeParser(t, MIMEApplicationCBOR, enc) }) t.Run("XML", func(t *testing.T) { - testDecodeParser(t, MIMEApplicationXML, `john`) + testDecodeParser(t, MIMEApplicationXML, []byte(`john`)) }) t.Run("Form", func(t *testing.T) { - testDecodeParser(t, MIMEApplicationForm, "name=john") + testDecodeParser(t, MIMEApplicationForm, []byte("name=john")) }) t.Run("MultipartForm", func(t *testing.T) { - testDecodeParser(t, MIMEMultipartForm+`;boundary="b"`, "--b\r\nContent-Disposition: form-data; name=\"name\"\r\n\r\njohn\r\n--b--") + testDecodeParser(t, MIMEMultipartForm+`;boundary="b"`, []byte("--b\r\nContent-Disposition: form-data; name=\"name\"\r\n\r\njohn\r\n--b--")) }) testDecodeParserError := func(t *testing.T, contentType, body string) { @@ -1091,6 +1099,35 @@ func Benchmark_Bind_Body_XML(b *testing.B) { require.Equal(b, "john", d.Name) } +// go test -v -run=^$ -bench=Benchmark_Bind_Body_CBOR -benchmem -count=4 +func Benchmark_Bind_Body_CBOR(b *testing.B) { + var err error + + app := New() + c := app.AcquireCtx(&fasthttp.RequestCtx{}) + + type Demo struct { + Name string `json:"name"` + } + body, err := cbor.Marshal(&Demo{Name: "john"}) + if err != nil { + b.Error(err) + } + c.Request().SetBody(body) + c.Request().Header.SetContentType(MIMEApplicationCBOR) + c.Request().Header.SetContentLength(len(body)) + d := new(Demo) + + b.ReportAllocs() + b.ResetTimer() + + for n := 0; n < b.N; n++ { + err = c.Bind().Body(d) + } + require.NoError(b, err) + require.Equal(b, "john", d.Name) +} + // go test -v -run=^$ -bench=Benchmark_Bind_Body_Form -benchmem -count=4 func Benchmark_Bind_Body_Form(b *testing.B) { var err error @@ -1710,9 +1747,16 @@ func Test_Bind_RepeatParserWithSameStruct(t *testing.T) { require.NoError(t, c.Bind().Body(r)) require.Equal(t, "body_param", r.BodyParam) } + cb, err := cbor.Marshal(&Request{ + BodyParam: "body_param", + }) + if err != nil { + t.Error(err) + } testDecodeParser(MIMEApplicationJSON, `{"body_param":"body_param"}`) testDecodeParser(MIMEApplicationXML, `body_param`) + testDecodeParser(MIMEApplicationCBOR, string(cb)) testDecodeParser(MIMEApplicationForm, "body_param=body_param") testDecodeParser(MIMEMultipartForm+`;boundary="b"`, "--b\r\nContent-Disposition: form-data; name=\"body_param\"\r\n\r\nbody_param\r\n--b--") } diff --git a/binder/binder.go b/binder/binder.go index fb7ac12dab..bb3fc2b394 100644 --- a/binder/binder.go +++ b/binder/binder.go @@ -20,4 +20,5 @@ var ( URIBinder = &uriBinding{} XMLBinder = &xmlBinding{} JSONBinder = &jsonBinding{} + CBORBinder = &cborBinding{} ) diff --git a/binder/cbor.go b/binder/cbor.go new file mode 100644 index 0000000000..12ed13715c --- /dev/null +++ b/binder/cbor.go @@ -0,0 +1,15 @@ +package binder + +import ( + "github.com/gofiber/utils/v2" +) + +type cborBinding struct{} + +func (*cborBinding) Name() string { + return "cbor" +} + +func (*cborBinding) Bind(body []byte, cborDecoder utils.CBORUnmarshal, out any) error { + return cborDecoder(body, out) +} diff --git a/client/client.go b/client/client.go index d9b9c84c63..3665ff21e6 100644 --- a/client/client.go +++ b/client/client.go @@ -13,6 +13,7 @@ import ( "sync" "time" + "github.com/fxamacker/cbor/v2" "github.com/gofiber/fiber/v3/log" "github.com/gofiber/utils/v2" @@ -44,6 +45,8 @@ type Client struct { jsonUnmarshal utils.JSONUnmarshal xmlMarshal utils.XMLMarshal xmlUnmarshal utils.XMLUnmarshal + cborMarshal utils.CBORMarshal + cborUnmarshal utils.CBORUnmarshal cookieJar *CookieJar @@ -150,6 +153,28 @@ func (c *Client) SetXMLUnmarshal(f utils.XMLUnmarshal) *Client { return c } +// CBORMarshal returns xml marshal function in Core. +func (c *Client) CBORMarshal() utils.CBORMarshal { + return c.cborMarshal +} + +// SetCBORMarshal Set xml encoder. +func (c *Client) SetCBORMarshal(f utils.CBORMarshal) *Client { + c.cborMarshal = f + return c +} + +// CBORUnmarshal returns xml unmarshal function in Core. +func (c *Client) CBORUnmarshal() utils.CBORUnmarshal { + return c.cborUnmarshal +} + +// SetCBORUnmarshal Set xml decoder. +func (c *Client) SetCBORUnmarshal(f utils.CBORUnmarshal) *Client { + c.cborUnmarshal = f + return c +} + // TLSConfig returns tlsConfig in client. // If client don't have tlsConfig, this function will init it. func (c *Client) TLSConfig() *tls.Config { @@ -698,6 +723,8 @@ func New() *Client { jsonMarshal: json.Marshal, jsonUnmarshal: json.Unmarshal, xmlMarshal: xml.Marshal, + cborMarshal: cbor.Marshal, + cborUnmarshal: cbor.Unmarshal, xmlUnmarshal: xml.Unmarshal, logger: log.DefaultLogger(), } diff --git a/client/client_test.go b/client/client_test.go index 0323f70ccd..c91c1da5db 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -3,6 +3,7 @@ package client import ( "context" "crypto/tls" + "encoding/hex" "errors" "io" "net" @@ -202,6 +203,33 @@ func Test_Client_Marshal(t *testing.T) { require.Equal(t, errors.New("empty xml"), err) }) + t.Run("set cbor marshal", func(t *testing.T) { + t.Parallel() + bs, err := hex.DecodeString("f6") + if err != nil { + t.Error(err) + } + client := New(). + SetCBORMarshal(func(_ any) ([]byte, error) { + return bs, nil + }) + val, err := client.CBORMarshal()(nil) + + require.NoError(t, err) + require.Equal(t, bs, val) + }) + + t.Run("set cbor marshal error", func(t *testing.T) { + t.Parallel() + client := New().SetCBORMarshal(func(_ any) ([]byte, error) { + return nil, errors.New("invalid struct") + }) + + val, err := client.CBORMarshal()(nil) + require.Nil(t, val) + require.Equal(t, errors.New("invalid struct"), err) + }) + t.Run("set xml unmarshal", func(t *testing.T) { t.Parallel() client := New(). diff --git a/client/hooks.go b/client/hooks.go index f11f9865f3..ec3987938e 100644 --- a/client/hooks.go +++ b/client/hooks.go @@ -23,6 +23,7 @@ var ( headerAccept = "Accept" applicationJSON = "application/json" + applicationCBOR = "application/cbor" applicationXML = "application/xml" applicationForm = "application/x-www-form-urlencoded" multipartFormData = "multipart/form-data" @@ -129,6 +130,8 @@ func parserRequestHeader(c *Client, req *Request) error { req.RawRequest.Header.Set(headerAccept, applicationJSON) case xmlBody: req.RawRequest.Header.SetContentType(applicationXML) + case cborBody: + req.RawRequest.Header.SetContentType(applicationCBOR) case formBody: req.RawRequest.Header.SetContentType(applicationForm) case filesBody: @@ -189,6 +192,12 @@ func parserRequestBody(c *Client, req *Request) error { return err } req.RawRequest.SetBody(body) + case cborBody: + body, err := c.cborMarshal(req.body) + if err != nil { + return err + } + req.RawRequest.SetBody(body) case formBody: req.RawRequest.SetBody(req.formData.QueryString()) case filesBody: diff --git a/client/request.go b/client/request.go index 61b5798c57..724358600a 100644 --- a/client/request.go +++ b/client/request.go @@ -34,6 +34,7 @@ const ( formBody filesBody rawBody + cborBody ) var ErrClientNil = errors.New("client can not be nil") @@ -337,6 +338,12 @@ func (r *Request) SetXML(v any) *Request { return r } +func (r *Request) SetCBOR(v any) *Request { + r.body = v + r.bodyType = cborBody + return r +} + // SetRawBody method sets body with raw data in request. func (r *Request) SetRawBody(v []byte) *Request { r.body = v diff --git a/client/response.go b/client/response.go index a8a032b6c5..f21290f764 100644 --- a/client/response.go +++ b/client/response.go @@ -75,6 +75,11 @@ func (r *Response) JSON(v any) error { return r.client.jsonUnmarshal(r.Body(), v) } +// CBOR method will unmarshal body to cbor. +func (r *Response) CBOR(v any) error { + return r.client.cborUnmarshal(r.Body(), v) +} + // XML method will unmarshal body to xml. func (r *Response) XML(v any) error { return r.client.xmlUnmarshal(r.Body(), v) diff --git a/constants.go b/constants.go index 4717204094..782195a2c0 100644 --- a/constants.go +++ b/constants.go @@ -23,6 +23,7 @@ const ( MIMETextCSS = "text/css" MIMEApplicationXML = "application/xml" MIMEApplicationJSON = "application/json" + MIMEApplicationCBOR = "application/cbor" // Deprecated: use MIMETextJavaScript instead MIMEApplicationJavaScript = "application/javascript" MIMEApplicationForm = "application/x-www-form-urlencoded" diff --git a/ctx.go b/ctx.go index a2eee2754b..28f9a9d27c 100644 --- a/ctx.go +++ b/ctx.go @@ -883,6 +883,24 @@ func (c *DefaultCtx) JSON(data any, ctype ...string) error { return nil } +// CBOR converts any interface or string to cbor encoded bytes. +// If the ctype parameter is given, this method will set the +// Content-Type header equal to ctype. If ctype is not given, +// The Content-Type header will be set to application/cbor. +func (c *DefaultCtx) CBOR(data any, ctype ...string) error { + raw, err := c.app.config.CBOREncoder(data) + if err != nil { + return err + } + c.fasthttp.Response.SetBodyRaw(raw) + if len(ctype) > 0 { + c.fasthttp.Response.Header.SetContentType(ctype[0]) + } else { + c.fasthttp.Response.Header.SetContentType(MIMEApplicationCBOR) + } + return nil +} + // JSONP sends a JSON response with JSONP support. // This method is identical to JSON, except that it opts-in to JSONP callback support. // By default, the callback name is simply callback. diff --git a/ctx_interface_gen.go b/ctx_interface_gen.go index 9fd434bc3e..8705073656 100644 --- a/ctx_interface_gen.go +++ b/ctx_interface_gen.go @@ -163,6 +163,11 @@ type Ctx interface { // Content-Type header equal to ctype. If ctype is not given, // The Content-Type header will be set to application/json. JSON(data any, ctype ...string) error + // CBOR converts any interface or string to cbor encoded bytes. + // If the ctype parameter is given, this method will set the + // Content-Type header equal to ctype. If ctype is not given, + // The Content-Type header will be set to application/cbor. + CBOR(data any, ctype ...string) error // JSONP sends a JSON response with JSONP support. // This method is identical to JSON, except that it opts-in to JSONP callback support. // By default, the callback name is simply callback. diff --git a/ctx_test.go b/ctx_test.go index af56197c58..cdab326396 100644 --- a/ctx_test.go +++ b/ctx_test.go @@ -12,6 +12,7 @@ import ( "context" "crypto/tls" "embed" + "encoding/hex" "encoding/xml" "errors" "fmt" @@ -3615,6 +3616,91 @@ func Benchmark_Ctx_JSON(b *testing.B) { require.JSONEq(b, `{"Name":"Grame","Age":20}`, string(c.Response().Body())) } +// go test -run Test_Ctx_CBOR +func Test_Ctx_CBOR(t *testing.T) { + t.Parallel() + app := New() + c := app.AcquireCtx(&fasthttp.RequestCtx{}) + + require.Error(t, c.CBOR(complex(1, 1))) + + type dummyStruct struct { + Name string + Age int + } + + // Test without ctype + err := c.CBOR(dummyStruct{ // map has no order + Name: "Grame", + Age: 20, + }) + require.NoError(t, err) + require.Equal(t, `a2644e616d65654772616d656341676514`, hex.EncodeToString(c.Response().Body())) + require.Equal(t, "application/cbor", string(c.Response().Header.Peek("content-type"))) + + // Test with ctype + err = c.CBOR(dummyStruct{ // map has no order + Name: "Grame", + Age: 20, + }, "application/problem+cbor") + require.NoError(t, err) + require.Equal(t, `a2644e616d65654772616d656341676514`, hex.EncodeToString(c.Response().Body())) + require.Equal(t, "application/problem+cbor", string(c.Response().Header.Peek("content-type"))) + + testEmpty := func(v any, r string) { + err := c.CBOR(v) + require.NoError(t, err) + require.Equal(t, r, hex.EncodeToString(c.Response().Body())) + } + + testEmpty(nil, "f6") + testEmpty("", `60`) + testEmpty(0, "00") + testEmpty([]int{}, "80") + + t.Run("custom cbor encoder", func(t *testing.T) { + t.Parallel() + + app := New(Config{ + CBOREncoder: func(_ any) ([]byte, error) { + return []byte(hex.EncodeToString([]byte("random"))), nil + }, + }) + c := app.AcquireCtx(&fasthttp.RequestCtx{}) + + err := c.CBOR(Map{ // map has no order + "Name": "Grame", + "Age": 20, + }) + require.NoError(t, err) + require.Equal(t, `72616e646f6d`, string(c.Response().Body())) + require.Equal(t, "application/cbor", string(c.Response().Header.Peek("content-type"))) + }) +} + +// go test -run=^$ -bench=Benchmark_Ctx_CBOR -benchmem -count=4 +func Benchmark_Ctx_CBOR(b *testing.B) { + app := New() + c := app.AcquireCtx(&fasthttp.RequestCtx{}) + + type SomeStruct struct { + Name string + Age uint8 + } + data := SomeStruct{ + Name: "Grame", + Age: 20, + } + var err error + b.ReportAllocs() + b.ResetTimer() + for n := 0; n < b.N; n++ { + err = c.CBOR(data) + } + require.NoError(b, err) + require.Equal(b, `a2644e616d65654772616d656341676514`, hex.EncodeToString(c.Response().Body())) +} + // go test -run=^$ -bench=Benchmark_Ctx_JSON_Ctype -benchmem -count=4 func Benchmark_Ctx_JSON_Ctype(b *testing.B) { app := New() diff --git a/docs/whats_new.md b/docs/whats_new.md index 0a47dca491..1ffdcfa447 100644 --- a/docs/whats_new.md +++ b/docs/whats_new.md @@ -196,6 +196,8 @@ DRAFT section - Cookie now allows Partitioned cookies for [CHIPS](https://developers.google.com/privacy-sandbox/3pcd/chips) support. CHIPS (Cookies Having Independent Partitioned State) is a feature that improves privacy by allowing cookies to be partitioned by top-level site, mitigating cross-site tracking. +- Introducing [CBOR](https://cbor.io/) binary encoding format for response body. CBOR is a binary data serialization format that is both compact and efficient, making it ideal for use in web applications. + ### new methods - AutoFormat -> ExpressJs like @@ -208,6 +210,7 @@ DRAFT section - SendString -> ExpressJs like - String -> ExpressJs like - ViewBind -> instead of Bind +- CBOR -> for CBOR encoding ### removed methods diff --git a/go.mod b/go.mod index 781a260a03..559556367f 100644 --- a/go.mod +++ b/go.mod @@ -17,10 +17,12 @@ require ( require ( github.com/andybalholm/brotli v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/fxamacker/cbor/v2 v2.7.0 // direct github.com/klauspost/compress v1.17.11 // indirect github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect + github.com/x448/float16 v0.8.4 // indirect golang.org/x/net v0.30.0 // indirect golang.org/x/sys v0.26.0 // indirect golang.org/x/text v0.19.0 // indirect diff --git a/middleware/cache/manager_msgp.go b/middleware/cache/manager_msgp.go index bf5d615200..492e9a88bd 100644 --- a/middleware/cache/manager_msgp.go +++ b/middleware/cache/manager_msgp.go @@ -52,6 +52,9 @@ func (z *item) DecodeMsg(dc *msgp.Reader) (err error) { err = msgp.WrapError(err, "headers", za0001) return } + if za0002 == nil { + za0002 = make([]byte, 0) + } z.headers[za0001] = za0002 } case "body": @@ -267,6 +270,9 @@ func (z *item) UnmarshalMsg(bts []byte) (o []byte, err error) { err = msgp.WrapError(err, "headers", za0001) return } + if za0002 == nil { + za0002 = make([]byte, 0) + } z.headers[za0001] = za0002 } case "body":