1
0
mirror of https://github.com/gofiber/fiber.git synced 2025-02-06 23:51:40 +00:00

🔥 v3: update Ctx.Format to match Express's res.format (#2766)

* 🔥 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
This commit is contained in:
nickajacks1 2024-01-04 00:50:36 -08:00 committed by GitHub
parent 2954e3bbae
commit 408fa20a91
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 328 additions and 36 deletions

60
ctx.go
View File

@ -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

View File

@ -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)

View File

@ -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, "<p>Hello, World!</p>", 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, `<string>Hello, World!</string>`, 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,
`<Message sender="Carol" urgency="3"><Recipients>Alice</Recipients><Recipients>Bob</Recipients></Message>`,
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, "<p>Hello, World!</p>", 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, `<string>Hello, World!</string>`, string(c.Response().Body()))

View File

@ -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!")
// => <p>Hello, World!</p>
type User struct {
Name string
}
user := User{"John Doe"}
// Accept: application/json
c.AutoFormat(user)
// => {"Name":"John Doe"}
// Accept: application/xml
c.AutoFormat(user)
// => <User><Name>John Doe</Name></User>
// ..
})
```
## 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!")
// => <p>Hello, World!</p>
// 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...)
})
```

View File

@ -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.