Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 4 commits into from
Jan 4, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 57 additions & 1 deletion ctx.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,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...)
Expand Down Expand Up @@ -371,9 +377,59 @@ 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)
efectn marked this conversation as resolved.
Show resolved Hide resolved
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.
// 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 {
ReneWerner87 marked this conversation as resolved.
Show resolved Hide resolved
// 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 ...ResFmt) 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
ReneWerner87 marked this conversation as resolved.
Show resolved Hide resolved

// FormFile returns the first file by key from a MultipartForm.
FormFile(key string) (*multipart.FileHeader, error)
Expand Down
156 changes: 136 additions & 20 deletions ctx_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -717,49 +717,165 @@ 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) {
// 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 +885,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 +902,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 +919,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 +936,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
Loading
Loading