diff --git a/ctx.go b/ctx.go index c576806f..0f660810 100644 --- a/ctx.go +++ b/ctx.go @@ -105,6 +105,12 @@ type Views interface { Render(io.Writer, string, any, ...string) error } +// ResFmt associates a Content Type to a fiber.Handler for c.Format +type ResFmt struct { + MediaType string + Handler func(Ctx) error +} + // Accepts checks if the specified extensions or content types are acceptable. func (c *DefaultCtx) Accepts(offers ...string) string { return getOffer(c.Get(HeaderAccept), acceptsOfferType, offers...) @@ -375,9 +381,61 @@ func (c *DefaultCtx) Response() *fasthttp.Response { } // Format performs content-negotiation on the Accept HTTP header. +// It uses Accepts to select a proper format and calls the matching +// user-provided handler function. +// If no accepted format is found, and a format with MediaType "default" is given, +// that default handler is called. If no format is found and no default is given, +// StatusNotAcceptable is sent. +func (c *DefaultCtx) Format(handlers ...ResFmt) error { + if len(handlers) == 0 { + return ErrNoHandlers + } + + c.Vary(HeaderAccept) + + if c.Get(HeaderAccept) == "" { + c.Response().Header.SetContentType(handlers[0].MediaType) + return handlers[0].Handler(c) + } + + // Using an int literal as the slice capacity allows for the slice to be + // allocated on the stack. The number was chosen arbitrarily as an + // approximation of the maximum number of content types a user might handle. + // If the user goes over, it just causes allocations, so it's not a problem. + types := make([]string, 0, 8) + var defaultHandler Handler + for _, h := range handlers { + if h.MediaType == "default" { + defaultHandler = h.Handler + continue + } + types = append(types, h.MediaType) + } + accept := c.Accepts(types...) + + if accept == "" { + if defaultHandler == nil { + return c.SendStatus(StatusNotAcceptable) + } + return defaultHandler(c) + } + + for _, h := range handlers { + if h.MediaType == accept { + c.Response().Header.SetContentType(h.MediaType) + return h.Handler(c) + } + } + + return fmt.Errorf("%w: format: an Accept was found but no handler was called", errUnreachable) +} + +// AutoFormat performs content-negotiation on the Accept HTTP header. // It uses Accepts to select a proper format. +// The supported content types are text/html, text/plain, application/json, and application/xml. +// For more flexible content negotiation, use Format. // If the header is not specified or there is no proper format, text/plain is used. -func (c *DefaultCtx) Format(body any) error { +func (c *DefaultCtx) AutoFormat(body any) error { // Get accepted content type accept := c.Accepts("html", "json", "txt", "xml") // Set accepted content type diff --git a/ctx_interface.go b/ctx_interface.go index af2317ff..b04fb457 100644 --- a/ctx_interface.go +++ b/ctx_interface.go @@ -90,9 +90,19 @@ type Ctx interface { Response() *fasthttp.Response // Format performs content-negotiation on the Accept HTTP header. + // It uses Accepts to select a proper format and calls the matching + // user-provided handler function. + // If no accepted format is found, and a format with MediaType "default" is given, + // that default handler is called. If no format is found and no default is given, + // StatusNotAcceptable is sent. + Format(handlers ...ResFmt) error + + // AutoFormat performs content-negotiation on the Accept HTTP header. // It uses Accepts to select a proper format. + // The supported content types are text/html, text/plain, application/json, and application/xml. + // For more flexible content negotiation, use Format. // If the header is not specified or there is no proper format, text/plain is used. - Format(body any) error + AutoFormat(body any) error // FormFile returns the first file by key from a MultipartForm. FormFile(key string) (*multipart.FileHeader, error) diff --git a/ctx_test.go b/ctx_test.go index 3debea82..4293fce1 100644 --- a/ctx_test.go +++ b/ctx_test.go @@ -717,49 +717,198 @@ func Test_Ctx_Format(t *testing.T) { app := New() c := app.NewCtx(&fasthttp.RequestCtx{}) + // set `accepted` to whatever media type was chosen by Format + var accepted string + formatHandlers := func(types ...string) []ResFmt { + fmts := []ResFmt{} + for _, t := range types { + t := utils.CopyString(t) + fmts = append(fmts, ResFmt{t, func(c Ctx) error { + accepted = t + return nil + }}) + } + return fmts + } + + c.Request().Header.Set(HeaderAccept, `text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7`) + err := c.Format(formatHandlers("application/xhtml+xml", "application/xml", "foo/bar")...) + require.Equal(t, "application/xhtml+xml", accepted) + require.Equal(t, "application/xhtml+xml", c.GetRespHeader(HeaderContentType)) + require.NoError(t, err) + require.NotEqual(t, StatusNotAcceptable, c.Response().StatusCode()) + + err = c.Format(formatHandlers("foo/bar;a=b")...) + require.Equal(t, "foo/bar;a=b", accepted) + require.Equal(t, "foo/bar;a=b", c.GetRespHeader(HeaderContentType)) + require.NoError(t, err) + require.NotEqual(t, StatusNotAcceptable, c.Response().StatusCode()) + + myError := errors.New("this is an error") + err = c.Format(ResFmt{"text/html", func(c Ctx) error { return myError }}) + require.ErrorIs(t, err, myError) + + c.Request().Header.Set(HeaderAccept, "application/json") + err = c.Format(ResFmt{"text/html", func(c Ctx) error { return c.SendStatus(StatusOK) }}) + require.Equal(t, StatusNotAcceptable, c.Response().StatusCode()) + require.NoError(t, err) + + err = c.Format(formatHandlers("text/html", "default")...) + require.Equal(t, "default", accepted) + require.Equal(t, "text/html", c.GetRespHeader(HeaderContentType)) + require.NoError(t, err) + + err = c.Format() + require.ErrorIs(t, err, ErrNoHandlers) +} + +func Benchmark_Ctx_Format(b *testing.B) { + app := New() + c := app.NewCtx(&fasthttp.RequestCtx{}) + c.Request().Header.Set(HeaderAccept, "application/json,text/plain; format=flowed; q=0.9") + + fail := func(_ Ctx) error { + require.FailNow(b, "Wrong type chosen") + return errors.New("Wrong type chosen") + } + ok := func(_ Ctx) error { + return nil + } + + var err error + b.Run("with arg allocation", func(b *testing.B) { + for n := 0; n < b.N; n++ { + err = c.Format( + ResFmt{"application/xml", fail}, + ResFmt{"text/html", fail}, + ResFmt{"text/plain;format=fixed", fail}, + ResFmt{"text/plain;format=flowed", ok}, + ) + } + require.NoError(b, err) + }) + + b.Run("pre-allocated args", func(b *testing.B) { + offers := []ResFmt{ + {"application/xml", fail}, + {"text/html", fail}, + {"text/plain;format=fixed", fail}, + {"text/plain;format=flowed", ok}, + } + for n := 0; n < b.N; n++ { + err = c.Format(offers...) + } + require.NoError(b, err) + }) + + c.Request().Header.Set("Accept", "text/plain") + b.Run("text/plain", func(b *testing.B) { + offers := []ResFmt{ + {"application/xml", fail}, + {"text/plain", ok}, + } + for n := 0; n < b.N; n++ { + err = c.Format(offers...) + } + require.NoError(b, err) + }) + + c.Request().Header.Set("Accept", "json") + b.Run("json", func(b *testing.B) { + offers := []ResFmt{ + {"xml", fail}, + {"html", fail}, + {"json", ok}, + } + for n := 0; n < b.N; n++ { + err = c.Format(offers...) + } + require.NoError(b, err) + }) +} + +// go test -run Test_Ctx_AutoFormat +func Test_Ctx_AutoFormat(t *testing.T) { + t.Parallel() + app := New() + c := app.NewCtx(&fasthttp.RequestCtx{}) + c.Request().Header.Set(HeaderAccept, MIMETextPlain) - err := c.Format([]byte("Hello, World!")) + err := c.AutoFormat([]byte("Hello, World!")) require.NoError(t, err) require.Equal(t, "Hello, World!", string(c.Response().Body())) c.Request().Header.Set(HeaderAccept, MIMETextHTML) - err = c.Format("Hello, World!") + err = c.AutoFormat("Hello, World!") require.NoError(t, err) require.Equal(t, "

Hello, World!

", string(c.Response().Body())) c.Request().Header.Set(HeaderAccept, MIMEApplicationJSON) - err = c.Format("Hello, World!") + err = c.AutoFormat("Hello, World!") require.NoError(t, err) require.Equal(t, `"Hello, World!"`, string(c.Response().Body())) c.Request().Header.Set(HeaderAccept, MIMETextPlain) - err = c.Format(complex(1, 1)) + err = c.AutoFormat(complex(1, 1)) require.NoError(t, err) require.Equal(t, "(1+1i)", string(c.Response().Body())) c.Request().Header.Set(HeaderAccept, MIMEApplicationXML) - err = c.Format("Hello, World!") + err = c.AutoFormat("Hello, World!") require.NoError(t, err) require.Equal(t, `Hello, World!`, string(c.Response().Body())) - err = c.Format(complex(1, 1)) + err = c.AutoFormat(complex(1, 1)) require.Error(t, err) c.Request().Header.Set(HeaderAccept, MIMETextPlain) - err = c.Format(Map{}) + err = c.AutoFormat(Map{}) require.NoError(t, err) require.Equal(t, "map[]", string(c.Response().Body())) type broken string c.Request().Header.Set(HeaderAccept, "broken/accept") require.NoError(t, err) - err = c.Format(broken("Hello, World!")) + err = c.AutoFormat(broken("Hello, World!")) require.NoError(t, err) require.Equal(t, `Hello, World!`, string(c.Response().Body())) } -// go test -v -run=^$ -bench=Benchmark_Ctx_Format -benchmem -count=4 -func Benchmark_Ctx_Format(b *testing.B) { +func Test_Ctx_AutoFormat_Struct(t *testing.T) { + t.Parallel() + app := New() + c := app.NewCtx(&fasthttp.RequestCtx{}) + + type Message struct { + Recipients []string + Sender string `xml:"sender,attr"` + Urgency int `xml:"urgency,attr"` + } + data := Message{ + Recipients: []string{"Alice", "Bob"}, + Sender: "Carol", + Urgency: 3, + } + + c.Request().Header.Set(HeaderAccept, MIMEApplicationJSON) + err := c.AutoFormat(data) + require.NoError(t, err) + require.Equal(t, + `{"Recipients":["Alice","Bob"],"Sender":"Carol","Urgency":3}`, + string(c.Response().Body()), + ) + + c.Request().Header.Set(HeaderAccept, MIMEApplicationXML) + err = c.AutoFormat(data) + require.NoError(t, err) + require.Equal(t, + `AliceBob`, + string(c.Response().Body()), + ) +} + +// go test -v -run=^$ -bench=Benchmark_Ctx_AutoFormat -benchmem -count=4 +func Benchmark_Ctx_AutoFormat(b *testing.B) { app := New() c := app.NewCtx(&fasthttp.RequestCtx{}) @@ -769,14 +918,14 @@ func Benchmark_Ctx_Format(b *testing.B) { var err error for n := 0; n < b.N; n++ { - err = c.Format("Hello, World!") + err = c.AutoFormat("Hello, World!") } require.NoError(b, err) require.Equal(b, `Hello, World!`, string(c.Response().Body())) } -// go test -v -run=^$ -bench=Benchmark_Ctx_Format_HTML -benchmem -count=4 -func Benchmark_Ctx_Format_HTML(b *testing.B) { +// go test -v -run=^$ -bench=Benchmark_Ctx_AutoFormat_HTML -benchmem -count=4 +func Benchmark_Ctx_AutoFormat_HTML(b *testing.B) { app := New() c := app.NewCtx(&fasthttp.RequestCtx{}) @@ -786,14 +935,14 @@ func Benchmark_Ctx_Format_HTML(b *testing.B) { var err error for n := 0; n < b.N; n++ { - err = c.Format("Hello, World!") + err = c.AutoFormat("Hello, World!") } require.NoError(b, err) require.Equal(b, "

Hello, World!

", string(c.Response().Body())) } -// go test -v -run=^$ -bench=Benchmark_Ctx_Format_JSON -benchmem -count=4 -func Benchmark_Ctx_Format_JSON(b *testing.B) { +// go test -v -run=^$ -bench=Benchmark_Ctx_AutoFormat_JSON -benchmem -count=4 +func Benchmark_Ctx_AutoFormat_JSON(b *testing.B) { app := New() c := app.NewCtx(&fasthttp.RequestCtx{}) @@ -803,14 +952,14 @@ func Benchmark_Ctx_Format_JSON(b *testing.B) { var err error for n := 0; n < b.N; n++ { - err = c.Format("Hello, World!") + err = c.AutoFormat("Hello, World!") } require.NoError(b, err) require.Equal(b, `"Hello, World!"`, string(c.Response().Body())) } -// go test -v -run=^$ -bench=Benchmark_Ctx_Format_XML -benchmem -count=4 -func Benchmark_Ctx_Format_XML(b *testing.B) { +// go test -v -run=^$ -bench=Benchmark_Ctx_AutoFormat_XML -benchmem -count=4 +func Benchmark_Ctx_AutoFormat_XML(b *testing.B) { app := New() c := app.NewCtx(&fasthttp.RequestCtx{}) @@ -820,7 +969,7 @@ func Benchmark_Ctx_Format_XML(b *testing.B) { var err error for n := 0; n < b.N; n++ { - err = c.Format("Hello, World!") + err = c.AutoFormat("Hello, World!") } require.NoError(b, err) require.Equal(b, `Hello, World!`, string(c.Response().Body())) diff --git a/docs/api/ctx.md b/docs/api/ctx.md index 23a07c58..25f30c16 100644 --- a/docs/api/ctx.md +++ b/docs/api/ctx.md @@ -184,6 +184,47 @@ app.Get("/", func(c *fiber.Ctx) error { }) ``` +## AutoFormat + +Performs content-negotiation on the [Accept](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept) HTTP header. It uses [Accepts](ctx.md#accepts) to select a proper format. +The supported content types are `text/html`, `text/plain`, `application/json`, and `application/xml`. +For more flexible content negotiation, use [Format](ctx.md#format). + + +:::info +If the header is **not** specified or there is **no** proper format, **text/plain** is used. +::: + +```go title="Signature" +func (c *Ctx) AutoFormat(body any) error +``` + +```go title="Example" +app.Get("/", func(c fiber.Ctx) error { + // Accept: text/plain + c.AutoFormat("Hello, World!") + // => Hello, World! + + // Accept: text/html + c.AutoFormat("Hello, World!") + // =>

Hello, World!

+ + type User struct { + Name string + } + user := User{"John Doe"} + + // Accept: application/json + c.AutoFormat(user) + // => {"Name":"John Doe"} + + // Accept: application/xml + c.AutoFormat(user) + // => John Doe + // .. +}) +``` + ## BaseURL Returns the base URL \(**protocol** + **host**\) as a `string`. @@ -510,30 +551,54 @@ app.Get("/", func(c *fiber.Ctx) error { ## Format -Performs content-negotiation on the [Accept](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept) HTTP header. It uses [Accepts](ctx.md#accepts) to select a proper format. +Performs content-negotiation on the [Accept](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept) HTTP header. It uses [Accepts](ctx.md#accepts) to select a proper format from the supplied offers. A default handler can be provided by setting the `MediaType` to `"default"`. If no offers match and no default is provided, a 406 (Not Acceptable) response is sent. The Content-Type is automatically set when a handler is selected. :::info -If the header is **not** specified or there is **no** proper format, **text/plain** is used. +If the Accept header is **not** specified, the first handler will be used. ::: ```go title="Signature" -func (c *Ctx) Format(body interface{}) error +func (c *Ctx) Format(handlers ...ResFmt) error ``` ```go title="Example" -app.Get("/", func(c *fiber.Ctx) error { - // Accept: text/plain - c.Format("Hello, World!") - // => Hello, World! +// Accept: application/json => {"command":"eat","subject":"fruit"} +// Accept: text/plain => Eat Fruit! +// Accept: application/xml => Not Acceptable +app.Get("/no-default", func(c fiber.Ctx) error { + return c.Format( + fiber.ResFmt{"application/json", func(c fiber.Ctx) error { + return c.JSON(fiber.Map{ + "command": "eat", + "subject": "fruit", + }) + }}, + fiber.ResFmt{"text/plain", func(c fiber.Ctx) error { + return c.SendString("Eat Fruit!") + }}, + ) +}) - // Accept: text/html - c.Format("Hello, World!") - // =>

Hello, World!

+// Accept: application/json => {"command":"eat","subject":"fruit"} +// Accept: text/plain => Eat Fruit! +// Accept: application/xml => Eat Fruit! +app.Get("/default", func(c fiber.Ctx) error { + textHandler := func(c fiber.Ctx) error { + return c.SendString("Eat Fruit!") + } - // Accept: application/json - c.Format("Hello, World!") - // => "Hello, World!" - // .. + handlers := []fiber.ResFmt{ + {"application/json", func(c fiber.Ctx) error { + return c.JSON(fiber.Map{ + "command": "eat", + "subject": "fruit", + }) + }}, + {"text/plain", textHandler}, + {"default", textHandler}, + } + + return c.Format(handlers...) }) ``` diff --git a/error.go b/error.go index 66b431cb..ea7a96d3 100644 --- a/error.go +++ b/error.go @@ -7,6 +7,10 @@ import ( "github.com/gofiber/fiber/v3/internal/schema" ) +// Wrap and return this for unreachable code if panicking is undesirable (i.e., in a handler). +// Unexported because users will hopefully never need to see it. +var errUnreachable = stdErrors.New("fiber: unreachable code, please create an issue at github.com/gofiber/fiber") + // Graceful shutdown errors var ( ErrGracefulTimeout = stdErrors.New("shutdown: graceful timeout has been reached, exiting") @@ -26,6 +30,12 @@ var ( // Binder errors var ErrCustomBinderNotFound = stdErrors.New("binder: custom binder not found, please be sure to enter the right name") +// Format errors +var ( + // ErrNoHandlers is returned when c.Format is called with no arguments. + ErrNoHandlers = stdErrors.New("format: at least one handler is required, but none were set") +) + // gorilla/schema errors type ( // ConversionError Conversion error exposes the internal schema.ConversionError for public use.