diff --git a/client.go b/client.go index fe1daf72..ee191a63 100644 --- a/client.go +++ b/client.go @@ -460,12 +460,16 @@ func (a *Agent) BodyStream(bodyStream io.Reader, bodySize int) *Agent { } // JSON sends a JSON request. -func (a *Agent) JSON(v interface{}) *Agent { +func (a *Agent) JSON(v interface{}, ctype ...string) *Agent { if a.jsonEncoder == nil { a.jsonEncoder = json.Marshal } - a.req.Header.SetContentType(MIMEApplicationJSON) + if len(ctype) > 0 { + a.req.Header.SetContentType(ctype[0]) + } else { + a.req.Header.SetContentType(MIMEApplicationJSON) + } if body, err := a.jsonEncoder(v); err != nil { a.errs = append(a.errs, err) diff --git a/client_test.go b/client_test.go index 395a18e0..580e2d35 100644 --- a/client_test.go +++ b/client_test.go @@ -651,6 +651,7 @@ func Test_Client_Agent_RetryIf(t *testing.T) { func Test_Client_Agent_Json(t *testing.T) { t.Parallel() + // Test without ctype parameter handler := func(c *Ctx) error { utils.AssertEqual(t, MIMEApplicationJSON, string(c.Request().Header.ContentType())) @@ -662,6 +663,19 @@ func Test_Client_Agent_Json(t *testing.T) { } testAgent(t, handler, wrapAgent, `{"success":true}`) + + // Test with ctype parameter + handler = func(c *Ctx) error { + utils.AssertEqual(t, "application/problem+json", string(c.Request().Header.ContentType())) + + return c.Send(c.Request().Body()) + } + + wrapAgent = func(a *Agent) { + a.JSON(data{Success: true}, "application/problem+json") + } + + testAgent(t, handler, wrapAgent, `{"success":true}`) } func Test_Client_Agent_Json_Error(t *testing.T) { diff --git a/ctx.go b/ctx.go index 944ea40b..55b81cf2 100644 --- a/ctx.go +++ b/ctx.go @@ -373,6 +373,7 @@ func decoderBuilder(parserConfig ParserConfig) interface{} { // BodyParser binds the request body to a struct. // It supports decoding the following content types based on the Content-Type header: // application/json, application/xml, application/x-www-form-urlencoded, multipart/form-data +// All JSON extenstion mime types are supported (eg. application/problem+json) // If none of the content types above are matched, it will return a ErrUnprocessableEntity error func (c *Ctx) BodyParser(out interface{}) error { // Get content-type @@ -380,8 +381,14 @@ func (c *Ctx) BodyParser(out interface{}) error { ctype = utils.ParseVendorSpecificContentType(ctype) + // Only use ctype string up to and excluding byte ';' + ctypeEnd := strings.IndexByte(ctype, ';') + if ctypeEnd != -1 { + ctype = ctype[:ctypeEnd] + } + // Parse body accordingly - if strings.HasPrefix(ctype, MIMEApplicationJSON) { + if strings.HasSuffix(ctype, "json") { return c.app.config.JSONDecoder(c.Body(), out) } if strings.HasPrefix(ctype, MIMEApplicationForm) { @@ -886,14 +893,20 @@ func (c *Ctx) Is(extension string) bool { // Array and slice values encode as JSON arrays, // except that []byte encodes as a base64-encoded string, // and a nil slice encodes as the null JSON value. -// This method also sets the content header to application/json. -func (c *Ctx) JSON(data interface{}) error { +// 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/json. +func (c *Ctx) JSON(data interface{}, ctype ...string) error { raw, err := c.app.config.JSONEncoder(data) if err != nil { return err } c.fasthttp.Response.SetBodyRaw(raw) - c.fasthttp.Response.Header.SetContentType(MIMEApplicationJSON) + if len(ctype) > 0 { + c.fasthttp.Response.Header.SetContentType(ctype[0]) + } else { + c.fasthttp.Response.Header.SetContentType(MIMEApplicationJSON) + } return nil } diff --git a/ctx_test.go b/ctx_test.go index 28f04699..16df8262 100644 --- a/ctx_test.go +++ b/ctx_test.go @@ -574,6 +574,9 @@ func Test_Ctx_BodyParser(t *testing.T) { testDecodeParser(MIMEApplicationForm, "name=john") testDecodeParser(MIMEMultipartForm+`;boundary="b"`, "--b\r\nContent-Disposition: form-data; name=\"name\"\r\n\r\njohn\r\n--b--") + // Ensure JSON extension MIME type gets parsed as JSON + testDecodeParser("application/problem+json", `{"name":"john"}`) + testDecodeParserError := func(contentType, body string) { c.Request().Header.SetContentType(contentType) c.Request().SetBody([]byte(body)) @@ -708,6 +711,30 @@ func Benchmark_Ctx_BodyParser_JSON(b *testing.B) { utils.AssertEqual(b, "john", d.Name) } +// go test -v -run=^$ -bench=Benchmark_Ctx_BodyParser_JSON_Extension -benchmem -count=4 +func Benchmark_Ctx_BodyParser_JSON_Extension(b *testing.B) { + app := New() + c := app.AcquireCtx(&fasthttp.RequestCtx{}) + defer app.ReleaseCtx(c) + type Demo struct { + Name string `json:"name"` + } + body := []byte(`{"name":"john"}`) + c.Request().SetBody(body) + c.Request().Header.SetContentType("application/problem+json") + c.Request().Header.SetContentLength(len(body)) + d := new(Demo) + + b.ReportAllocs() + b.ResetTimer() + + for n := 0; n < b.N; n++ { + _ = c.BodyParser(d) //nolint:errcheck // It is fine to ignore the error here as we check it once further below + } + utils.AssertEqual(b, nil, c.BodyParser(d)) + utils.AssertEqual(b, "john", d.Name) +} + // go test -v -run=^$ -bench=Benchmark_Ctx_BodyParser_XML -benchmem -count=4 func Benchmark_Ctx_BodyParser_XML(b *testing.B) { app := New() @@ -2927,6 +2954,7 @@ func Test_Ctx_JSON(t *testing.T) { utils.AssertEqual(t, true, c.JSON(complex(1, 1)) != nil) + // Test without ctype err := c.JSON(Map{ // map has no order "Name": "Grame", "Age": 20, @@ -2935,6 +2963,15 @@ func Test_Ctx_JSON(t *testing.T) { utils.AssertEqual(t, `{"Age":20,"Name":"Grame"}`, string(c.Response().Body())) utils.AssertEqual(t, "application/json", string(c.Response().Header.Peek("content-type"))) + // Test with ctype + err = c.JSON(Map{ // map has no order + "Name": "Grame", + "Age": 20, + }, "application/problem+json") + utils.AssertEqual(t, nil, err) + utils.AssertEqual(t, `{"Age":20,"Name":"Grame"}`, string(c.Response().Body())) + utils.AssertEqual(t, "application/problem+json", string(c.Response().Header.Peek("content-type"))) + testEmpty := func(v interface{}, r string) { err := c.JSON(v) utils.AssertEqual(t, nil, err) @@ -2990,6 +3027,30 @@ func Benchmark_Ctx_JSON(b *testing.B) { utils.AssertEqual(b, `{"Name":"Grame","Age":20}`, string(c.Response().Body())) } +// go test -run=^$ -bench=Benchmark_Ctx_JSON_Ctype -benchmem -count=4 +func Benchmark_Ctx_JSON_Ctype(b *testing.B) { + app := New() + c := app.AcquireCtx(&fasthttp.RequestCtx{}) + defer app.ReleaseCtx(c) + 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.JSON(data, "application/problem+json") + } + utils.AssertEqual(b, nil, err) + utils.AssertEqual(b, `{"Name":"Grame","Age":20}`, string(c.Response().Body())) + utils.AssertEqual(b, "application/problem+json", string(c.Response().Header.Peek("content-type"))) +} + // go test -run Test_Ctx_JSONP func Test_Ctx_JSONP(t *testing.T) { t.Parallel() diff --git a/docs/api/ctx.md b/docs/api/ctx.md index 860799f4..7b195683 100644 --- a/docs/api/ctx.md +++ b/docs/api/ctx.md @@ -797,11 +797,11 @@ app.Get("/", func(c *fiber.Ctx) error { Converts any **interface** or **string** to JSON using the [encoding/json](https://pkg.go.dev/encoding/json) package. :::info -JSON also sets the content header to **application/json**. +JSON also sets the content header to the `ctype` parameter. If no `ctype` is passed in, the header is set to **application/json**. ::: ```go title="Signature" -func (c *Ctx) JSON(data interface{}) error +func (c *Ctx) JSON(data interface{}, ctype ...string) error ``` ```go title="Example" @@ -827,6 +827,22 @@ app.Get("/json", func(c *fiber.Ctx) error { }) // => Content-Type: application/json // => "{"name": "Grame", "age": 20}" + + return c.JSON(fiber.Map{ + "type": "https://example.com/probs/out-of-credit", + "title": "You do not have enough credit.", + "status": 403, + "detail": "Your current balance is 30, but that costs 50.", + "instance": "/account/12345/msgs/abc", + }, "application/problem+json") + // => Content-Type: application/problem+json + // => "{ + // => "type": "https://example.com/probs/out-of-credit", + // => "title": "You do not have enough credit.", + // => "status": 403, + // => "detail": "Your current balance is 30, but that costs 50.", + // => "instance": "/account/12345/msgs/abc", + // => }" }) ```