From 408fa20a91f156d47c680961b530347d1dce74b7 Mon Sep 17 00:00:00 2001 From: nickajacks1 <128185314+nickajacks1@users.noreply.github.com> Date: Thu, 4 Jan 2024 00:50:36 -0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=A5=20v3:=20update=20Ctx.Format=20to?= =?UTF-8?q?=20match=20Express's=20res.format=20(#2766)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🔥 v3: update Ctx.Format to match Express's res.format While the existing Ctx.Format provides a concise convenience method for basic content negotiation on simple structures, res.format allows developers to set their own custom handlers for each content type. The existing Ctx.Format is renamed to Ctx.AutoFormat. * doc: add docs for Ctx.Format * refactor: update based on code review feedback - Rename Fmt to ResFmt - Add comments in several places - Return errors instead of panicking in Format - Add 'Accept' to the Vary header in Format to match res.format * chore: improve docs and tests for AutoFormat and Format --- ctx.go | 60 ++++++++++++++- ctx_interface.go | 12 ++- ctx_test.go | 189 ++++++++++++++++++++++++++++++++++++++++++----- docs/api/ctx.md | 93 +++++++++++++++++++---- error.go | 10 +++ 5 files changed, 328 insertions(+), 36 deletions(-) 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())) } -// 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!
+ + type User struct { + Name string + } + user := User{"John Doe"} + + // Accept: application/json + c.AutoFormat(user) + // => {"Name":"John Doe"} + + // Accept: application/xml + c.AutoFormat(user) + // =>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.