Skip to content

Commit

Permalink
🔥 v3: update Ctx.Format to match Express's res.format
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
nickajacks1 committed Dec 16, 2023
1 parent f37238e commit 0d4d1c0
Show file tree
Hide file tree
Showing 3 changed files with 173 additions and 22 deletions.
43 changes: 42 additions & 1 deletion ctx.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"bytes"
"context"
"crypto/tls"
"errors"
"fmt"
"io"
"mime/multipart"
Expand Down Expand Up @@ -370,10 +371,50 @@ func (c *DefaultCtx) Response() *fasthttp.Response {
return &c.fasthttp.Response
}

type Fmt struct {
MediaType string
Handler func(Ctx) error
}

// 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 ...Fmt) error {
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 {
return h.Handler(c)
}
}

// unreachable code
panic(errors.New("fiber: an Accept was found but no handler was called - please file a bug report"))
}

// AutoFormat performs content-negotiation on the Accept HTTP header.
// It uses Accepts to select a proper 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
Expand Down
10 changes: 9 additions & 1 deletion ctx_interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,17 @@ 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 ...Fmt) error

// AutoFormat performs content-negotiation on the Accept HTTP header.
// It uses Accepts to select a proper 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)
Expand Down
142 changes: 122 additions & 20 deletions ctx_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -717,49 +717,151 @@ 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) []Fmt {
fmts := []Fmt{}
for _, t := range types {
t := utils.CopyString(t)
fmts = append(fmts, Fmt{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.NoError(t, err)
require.NotEqual(t, StatusNotAcceptable, c.Response().StatusCode())

c.Format(formatHandlers("foo/bar;a=b")...)

Check failure on line 740 in ctx_test.go

View workflow job for this annotation

GitHub Actions / lint

Error return value of `c.Format` is not checked (errcheck)
require.Equal(t, "foo/bar;a=b", accepted)
require.NotEqual(t, StatusNotAcceptable, c.Response().StatusCode())

myError := errors.New("this is an error")
err = c.Format(Fmt{"text/html", func(c Ctx) error { return myError }})
require.ErrorIs(t, err, myError)

c.Request().Header.Set(HeaderAccept, "application/json")
c.Format(Fmt{"text/html", func(c Ctx) error { return c.SendStatus(StatusOK) }})

Check failure on line 749 in ctx_test.go

View workflow job for this annotation

GitHub Actions / lint

Error return value of `c.Format` is not checked (errcheck)
require.Equal(t, StatusNotAcceptable, c.Response().StatusCode())

c.Format(formatHandlers("text/html", "default")...)

Check failure on line 752 in ctx_test.go

View workflow job for this annotation

GitHub Actions / lint

Error return value of `c.Format` is not checked (errcheck)
require.Equal(t, "default", accepted)
}

func Benchmark_Ctx_Format(b *testing.B) {

Check warning on line 756 in ctx_test.go

View workflow job for this annotation

GitHub Actions / lint

empty-lines: extra empty line at the end of a block (revive)
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 {

Check failure on line 761 in ctx_test.go

View workflow job for this annotation

GitHub Actions / lint

Benchmark_Ctx_Format$1 - result 0 (error) is always nil (unparam)
require.FailNow(b, "Wrong type chosen")
return nil
}
ok := func(_ Ctx) error {
return nil
}
b.Run("args constructed each time", func(b *testing.B) {
for n := 0; n < b.N; n++ {
c.Format(
Fmt{"application/xml", fail},
Fmt{"text/html", fail},
Fmt{"text/plain;format=fixed", fail},
Fmt{"text/plain;format=flowed", ok},
)
}
})

b.Run("pre-allocated args", func(b *testing.B) {
offers := []Fmt{
{"application/xml", fail},
{"text/html", fail},
{"text/plain;format=fixed", fail},
{"text/plain;format=flowed", ok},
}
for n := 0; n < b.N; n++ {
c.Format(offers...)
}
})

c.Request().Header.Set("Accept", "text/plain")
b.Run("text/plain", func(b *testing.B) {
offers := []Fmt{
{"application/xml", fail},
{"text/plain", ok},
}
for n := 0; n < b.N; n++ {
c.Format(offers...)
}
})

c.Request().Header.Set("Accept", "json")
b.Run("json", func(b *testing.B) {
offers := []Fmt{
{"xml", fail},
{"html", fail},
{"json", ok},
}
for n := 0; n < b.N; n++ {
c.Format(offers...)
}
})

Check failure on line 813 in ctx_test.go

View workflow job for this annotation

GitHub Actions / lint

File is not `gofumpt`-ed with `-extra` (gofumpt)
}

// 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) {
// 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{})

Expand All @@ -769,14 +871,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{})

Expand All @@ -786,14 +888,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{})

Expand All @@ -803,14 +905,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{})

Expand All @@ -820,7 +922,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()))
Expand Down

0 comments on commit 0d4d1c0

Please sign in to comment.