🔥 Feature: Add Req and Res API (#2894)

* 🔥 feat: add Req and Res interfaces

Split the existing Ctx API into two separate APIs for Requests and
Responses. There are two goals to this change:

1. Reduce cognitive load by making it more obvious whether a Ctx method
   interacts with the request or the response.
2. Increase API parity with Express.

* fix(req,res): several issues

* Sprinkle in calls to Req() and Res() to a few unit tests
* Fix improper initialization caught by ^
* Add a few missing methods

* docs: organize Ctx methods by request and response

* feat(req,res): sync more missed methods

---------

Co-authored-by: Juan Calderon-Perez <835733+gaby@users.noreply.github.com>
pull/3338/head^2
nickajacks1 2025-03-04 23:01:43 -08:00 committed by GitHub
parent 8e54c8f938
commit 64c1771c26
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 1443 additions and 1039 deletions

14
ctx.go
View File

@ -54,6 +54,8 @@ type DefaultCtx struct {
fasthttp *fasthttp.RequestCtx // Reference to *fasthttp.RequestCtx
bind *Bind // Default bind reference
redirect *Redirect // Default redirect reference
req *DefaultReq // Default request api reference
res *DefaultRes // Default response api reference
values [maxParams]string // Route parameter values
viewBindMap sync.Map // Default view map to bind template engine
method string // HTTP method
@ -1463,6 +1465,18 @@ func (c *DefaultCtx) renderExtensions(bind any) {
}
}
// Req returns a convenience type whose API is limited to operations
// on the incoming request.
func (c *DefaultCtx) Req() Req {
return c.req
}
// Res returns a convenience type whose API is limited to operations
// on the outgoing response.
func (c *DefaultCtx) Res() Res {
return c.res
}
// Route returns the matched Route struct.
func (c *DefaultCtx) Route() *Route {
if c.route == nil {

View File

@ -32,10 +32,14 @@ type CustomCtx interface {
func NewDefaultCtx(app *App) *DefaultCtx {
// return ctx
return &DefaultCtx{
ctx := &DefaultCtx{
// Set app reference
app: app,
}
ctx.req = &DefaultReq{ctx: ctx}
ctx.res = &DefaultRes{ctx: ctx}
return ctx
}
func (app *App) newCtx() Ctx {

View File

@ -265,6 +265,12 @@ type Ctx interface {
// We support the following engines: https://github.com/gofiber/template
Render(name string, bind any, layouts ...string) error
renderExtensions(bind any)
// Req returns a convenience type whose API is limited to operations
// on the incoming request.
Req() Req
// Res returns a convenience type whose API is limited to operations
// on the outgoing response.
Res() Res
// Route returns the matched Route struct.
Route() *Route
// SaveFile saves any multipart file to disk.

View File

@ -46,7 +46,7 @@ func Test_Ctx_Accepts(t *testing.T) {
c.Request().Header.Set(HeaderAccept, "text/html,application/xhtml+xml,application/xml;q=0.9")
require.Equal(t, "", c.Accepts(""))
require.Equal(t, "", c.Accepts())
require.Equal(t, "", c.Req().Accepts())
require.Equal(t, ".xml", c.Accepts(".xml"))
require.Equal(t, "", c.Accepts(".john"))
require.Equal(t, "application/xhtml+xml", c.Accepts("application/xml", "application/xml+rss", "application/yaml", "application/xhtml+xml"), "must use client-preferred mime type")
@ -57,13 +57,13 @@ func Test_Ctx_Accepts(t *testing.T) {
c.Request().Header.Set(HeaderAccept, "text/*, application/json")
require.Equal(t, "html", c.Accepts("html"))
require.Equal(t, "text/html", c.Accepts("text/html"))
require.Equal(t, "json", c.Accepts("json", "text"))
require.Equal(t, "json", c.Req().Accepts("json", "text"))
require.Equal(t, "application/json", c.Accepts("application/json"))
require.Equal(t, "", c.Accepts("image/png"))
require.Equal(t, "", c.Accepts("png"))
c.Request().Header.Set(HeaderAccept, "text/html, application/json")
require.Equal(t, "text/*", c.Accepts("text/*"))
require.Equal(t, "text/*", c.Req().Accepts("text/*"))
c.Request().Header.Set(HeaderAccept, "*/*")
require.Equal(t, "html", c.Accepts("html"))
@ -968,46 +968,46 @@ func Test_Ctx_Cookie(t *testing.T) {
Expires: expire,
// SameSite: CookieSameSiteStrictMode, // default is "lax"
}
c.Cookie(cookie)
c.Res().Cookie(cookie)
expect := "username=john; expires=" + httpdate + "; path=/; SameSite=Lax"
require.Equal(t, expect, string(c.Response().Header.Peek(HeaderSetCookie)))
require.Equal(t, expect, c.Res().Get(HeaderSetCookie))
expect = "username=john; expires=" + httpdate + "; path=/"
cookie.SameSite = CookieSameSiteDisabled
c.Cookie(cookie)
require.Equal(t, expect, string(c.Response().Header.Peek(HeaderSetCookie)))
c.Res().Cookie(cookie)
require.Equal(t, expect, c.Res().Get(HeaderSetCookie))
expect = "username=john; expires=" + httpdate + "; path=/; SameSite=Strict"
cookie.SameSite = CookieSameSiteStrictMode
c.Cookie(cookie)
require.Equal(t, expect, string(c.Response().Header.Peek(HeaderSetCookie)))
c.Res().Cookie(cookie)
require.Equal(t, expect, c.Res().Get(HeaderSetCookie))
expect = "username=john; expires=" + httpdate + "; path=/; secure; SameSite=None"
cookie.Secure = true
cookie.SameSite = CookieSameSiteNoneMode
c.Cookie(cookie)
require.Equal(t, expect, string(c.Response().Header.Peek(HeaderSetCookie)))
c.Res().Cookie(cookie)
require.Equal(t, expect, c.Res().Get(HeaderSetCookie))
expect = "username=john; path=/; secure; SameSite=None"
// should remove expires and max-age headers
cookie.SessionOnly = true
cookie.Expires = expire
cookie.MaxAge = 10000
c.Cookie(cookie)
require.Equal(t, expect, string(c.Response().Header.Peek(HeaderSetCookie)))
c.Res().Cookie(cookie)
require.Equal(t, expect, c.Res().Get(HeaderSetCookie))
expect = "username=john; path=/; secure; SameSite=None"
// should remove expires and max-age headers when no expire and no MaxAge (default time)
cookie.SessionOnly = false
cookie.Expires = time.Time{}
cookie.MaxAge = 0
c.Cookie(cookie)
require.Equal(t, expect, string(c.Response().Header.Peek(HeaderSetCookie)))
c.Res().Cookie(cookie)
require.Equal(t, expect, c.Res().Get(HeaderSetCookie))
expect = "username=john; path=/; secure; SameSite=None; Partitioned"
cookie.Partitioned = true
c.Cookie(cookie)
require.Equal(t, expect, string(c.Response().Header.Peek(HeaderSetCookie)))
c.Res().Cookie(cookie)
require.Equal(t, expect, c.Res().Get(HeaderSetCookie))
}
// go test -v -run=^$ -bench=Benchmark_Ctx_Cookie -benchmem -count=4
@ -1033,8 +1033,8 @@ func Test_Ctx_Cookies(t *testing.T) {
c := app.AcquireCtx(&fasthttp.RequestCtx{})
c.Request().Header.Set("Cookie", "john=doe")
require.Equal(t, "doe", c.Cookies("john"))
require.Equal(t, "default", c.Cookies("unknown", "default"))
require.Equal(t, "doe", c.Req().Cookies("john"))
require.Equal(t, "default", c.Req().Cookies("unknown", "default"))
}
// go test -run Test_Ctx_Format
@ -1058,13 +1058,13 @@ func Test_Ctx_Format(t *testing.T) {
}
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")...)
err := c.Res().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")...)
err = c.Res().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)
@ -1165,7 +1165,7 @@ func Test_Ctx_AutoFormat(t *testing.T) {
require.Equal(t, "Hello, World!", string(c.Response().Body()))
c.Request().Header.Set(HeaderAccept, MIMETextHTML)
err = c.AutoFormat("Hello, World!")
err = c.Res().AutoFormat("Hello, World!")
require.NoError(t, err)
require.Equal(t, "<p>Hello, World!</p>", string(c.Response().Body()))
@ -1175,7 +1175,7 @@ func Test_Ctx_AutoFormat(t *testing.T) {
require.Equal(t, `"Hello, World!"`, string(c.Response().Body()))
c.Request().Header.Set(HeaderAccept, MIMETextPlain)
err = c.AutoFormat(complex(1, 1))
err = c.Res().AutoFormat(complex(1, 1))
require.NoError(t, err)
require.Equal(t, "(1+1i)", string(c.Response().Body()))
@ -2939,7 +2939,7 @@ func Test_Ctx_SaveFile(t *testing.T) {
app := New()
app.Post("/test", func(c Ctx) error {
fh, err := c.FormFile("file")
fh, err := c.Req().FormFile("file")
require.NoError(t, err)
tempFile, err := os.CreateTemp(os.TempDir(), "test-")
@ -3075,7 +3075,7 @@ func Test_Ctx_ClearCookie(t *testing.T) {
c := app.AcquireCtx(&fasthttp.RequestCtx{})
c.Request().Header.Set(HeaderCookie, "john=doe")
c.ClearCookie("john")
c.Res().ClearCookie("john")
require.True(t, strings.HasPrefix(string(c.Response().Header.Peek(HeaderSetCookie)), "john=; expires="))
c.Request().Header.Set(HeaderCookie, "test1=dummy")
@ -3104,7 +3104,7 @@ func Test_Ctx_Download(t *testing.T) {
require.Equal(t, expect, c.Response().Body())
require.Equal(t, `attachment; filename="Awesome+File%21"`, string(c.Response().Header.Peek(HeaderContentDisposition)))
require.NoError(t, c.Download("ctx.go"))
require.NoError(t, c.Res().Download("ctx.go"))
require.Equal(t, `attachment; filename="ctx.go"`, string(c.Response().Header.Peek(HeaderContentDisposition)))
}
@ -3136,7 +3136,7 @@ func Test_Ctx_SendFile(t *testing.T) {
// test with custom error code
c = app.AcquireCtx(&fasthttp.RequestCtx{})
err = c.Status(StatusInternalServerError).SendFile("ctx.go")
err = c.Res().Status(StatusInternalServerError).SendFile("ctx.go")
// check expectation
require.NoError(t, err)
require.Equal(t, expectFileContent, c.Response().Body())
@ -3161,7 +3161,7 @@ func Test_Ctx_SendFile_ContentType(t *testing.T) {
// 1) simple case
c := app.AcquireCtx(&fasthttp.RequestCtx{})
err := c.SendFile("./.github/testdata/fs/img/fiber.png")
err := c.Res().SendFile("./.github/testdata/fs/img/fiber.png")
// check expectation
require.NoError(t, err)
require.Equal(t, StatusOK, c.Response().StatusCode())
@ -3782,7 +3782,7 @@ func Test_Ctx_JSONP(t *testing.T) {
require.Equal(t, `callback({"Age":20,"Name":"Grame"});`, string(c.Response().Body()))
require.Equal(t, "text/javascript; charset=utf-8", string(c.Response().Header.Peek("content-type")))
err = c.JSONP(Map{
err = c.Res().JSONP(Map{
"Name": "Grame",
"Age": 20,
}, "john")
@ -4006,7 +4006,7 @@ func Test_Ctx_Render(t *testing.T) {
err = c.Render("./.github/testdata/template-non-exists.html", nil)
require.Error(t, err)
err = c.Render("./.github/testdata/template-invalid.html", nil)
err = c.Res().Render("./.github/testdata/template-invalid.html", nil)
require.Error(t, err)
}
@ -4907,7 +4907,7 @@ func Test_Ctx_Queries(t *testing.T) {
c.Request().URI().SetQueryString("tags=apple,orange,banana&filters[tags]=apple,orange,banana&filters[category][name]=fruits&filters.tags=apple,orange,banana&filters.category.name=fruits")
queries = c.Queries()
queries = c.Req().Queries()
require.Equal(t, "apple,orange,banana", queries["tags"])
require.Equal(t, "apple,orange,banana", queries["filters[tags]"])
require.Equal(t, "fruits", queries["filters[category][name]"])
@ -5055,7 +5055,7 @@ func Test_Ctx_IsFromLocal_X_Forwarded(t *testing.T) {
c := app.AcquireCtx(&fasthttp.RequestCtx{})
c.Request().Header.Set(HeaderXForwardedFor, "93.46.8.90")
require.False(t, c.IsFromLocal())
require.False(t, c.Req().IsFromLocal())
}
}
@ -5088,8 +5088,8 @@ func Test_Ctx_IsFromLocal_RemoteAddr(t *testing.T) {
fastCtx := &fasthttp.RequestCtx{}
fastCtx.SetRemoteAddr(localIPv6)
c := app.AcquireCtx(fastCtx)
require.Equal(t, "::1", c.IP())
require.True(t, c.IsFromLocal())
require.Equal(t, "::1", c.Req().IP())
require.True(t, c.Req().IsFromLocal())
}
// Test for the case fasthttp remoteAddr is set to "0:0:0:0:0:0:0:1".
{

File diff suppressed because it is too large Load Diff

159
req.go Normal file
View File

@ -0,0 +1,159 @@
package fiber
import (
"crypto/tls"
"mime/multipart"
)
//go:generate ifacemaker --file req.go --struct DefaultReq --iface Req --pkg fiber --output req_interface_gen.go --not-exported true --iface-comment "Req"
type DefaultReq struct {
ctx *DefaultCtx
}
func (r *DefaultReq) Accepts(offers ...string) string {
return r.ctx.Accepts(offers...)
}
func (r *DefaultReq) AcceptsCharsets(offers ...string) string {
return r.ctx.AcceptsCharsets(offers...)
}
func (r *DefaultReq) AcceptsEncodings(offers ...string) string {
return r.ctx.AcceptsEncodings(offers...)
}
func (r *DefaultReq) AcceptsLanguages(offers ...string) string {
return r.ctx.AcceptsLanguages(offers...)
}
func (r *DefaultReq) BaseURL() string {
return r.ctx.BaseURL()
}
func (r *DefaultReq) Body() []byte {
return r.ctx.Body()
}
func (r *DefaultReq) BodyRaw() []byte {
return r.ctx.BodyRaw()
}
func (r *DefaultReq) ClientHelloInfo() *tls.ClientHelloInfo {
return r.ctx.ClientHelloInfo()
}
func (r *DefaultReq) Cookies(key string, defaultValue ...string) string {
return r.ctx.Cookies(key, defaultValue...)
}
func (r *DefaultReq) FormFile(key string) (*multipart.FileHeader, error) {
return r.ctx.FormFile(key)
}
func (r *DefaultReq) FormValue(key string, defaultValue ...string) string {
return r.ctx.FormValue(key, defaultValue...)
}
func (r *DefaultReq) Fresh() bool {
return r.ctx.Fresh()
}
func (r *DefaultReq) Get(key string, defaultValue ...string) string {
return r.ctx.Get(key, defaultValue...)
}
func (r *DefaultReq) Host() string {
return r.ctx.Host()
}
func (r *DefaultReq) Hostname() string {
return r.ctx.Hostname()
}
func (r *DefaultReq) IP() string {
return r.ctx.IP()
}
func (r *DefaultReq) IPs() []string {
return r.ctx.IPs()
}
func (r *DefaultReq) Is(extension string) bool {
return r.ctx.Is(extension)
}
func (r *DefaultReq) IsFromLocal() bool {
return r.ctx.IsFromLocal()
}
func (r *DefaultReq) IsProxyTrusted() bool {
return r.ctx.IsProxyTrusted()
}
func (r *DefaultReq) Method(override ...string) string {
return r.ctx.Method(override...)
}
func (r *DefaultReq) MultipartForm() (*multipart.Form, error) {
return r.ctx.MultipartForm()
}
func (r *DefaultReq) OriginalURL() string {
return r.ctx.OriginalURL()
}
func (r *DefaultReq) Params(key string, defaultValue ...string) string {
return r.ctx.Params(key, defaultValue...)
}
func (r *DefaultReq) Path(override ...string) string {
return r.ctx.Path(override...)
}
func (r *DefaultReq) Port() string {
return r.ctx.Port()
}
func (r *DefaultReq) Protocol() string {
return r.ctx.Protocol()
}
func (r *DefaultReq) Queries() map[string]string {
return r.ctx.Queries()
}
func (r *DefaultReq) Query(key string, defaultValue ...string) string {
return r.ctx.Query(key, defaultValue...)
}
func (r *DefaultReq) Range(size int) (Range, error) {
return r.ctx.Range(size)
}
func (r *DefaultReq) Route() *Route {
return r.ctx.Route()
}
func (r *DefaultReq) SaveFile(fileheader *multipart.FileHeader, path string) error {
return r.ctx.SaveFile(fileheader, path)
}
func (r *DefaultReq) SaveFileToStorage(fileheader *multipart.FileHeader, path string, storage Storage) error {
return r.ctx.SaveFileToStorage(fileheader, path, storage)
}
func (r *DefaultReq) Secure() bool {
return r.ctx.Secure()
}
func (r *DefaultReq) Stale() bool {
return r.ctx.Stale()
}
func (r *DefaultReq) Subdomains(offset ...int) []string {
return r.ctx.Subdomains(offset...)
}
func (r *DefaultReq) XHR() bool {
return r.ctx.XHR()
}

49
req_interface_gen.go Normal file
View File

@ -0,0 +1,49 @@
// Code generated by ifacemaker; DO NOT EDIT.
package fiber
import (
"crypto/tls"
"mime/multipart"
)
// Req
type Req interface {
Accepts(offers ...string) string
AcceptsCharsets(offers ...string) string
AcceptsEncodings(offers ...string) string
AcceptsLanguages(offers ...string) string
BaseURL() string
Body() []byte
BodyRaw() []byte
ClientHelloInfo() *tls.ClientHelloInfo
Cookies(key string, defaultValue ...string) string
FormFile(key string) (*multipart.FileHeader, error)
FormValue(key string, defaultValue ...string) string
Fresh() bool
Get(key string, defaultValue ...string) string
Host() string
Hostname() string
IP() string
IPs() []string
Is(extension string) bool
IsFromLocal() bool
IsProxyTrusted() bool
Method(override ...string) string
MultipartForm() (*multipart.Form, error)
OriginalURL() string
Params(key string, defaultValue ...string) string
Path(override ...string) string
Port() string
Protocol() string
Queries() map[string]string
Query(key string, defaultValue ...string) string
Range(size int) (Range, error)
Route() *Route
SaveFile(fileheader *multipart.FileHeader, path string) error
SaveFileToStorage(fileheader *multipart.FileHeader, path string, storage Storage) error
Secure() bool
Stale() bool
Subdomains(offset ...int) []string
XHR() bool
}

118
res.go Normal file
View File

@ -0,0 +1,118 @@
package fiber
import (
"bufio"
)
//go:generate ifacemaker --file res.go --struct DefaultRes --iface Res --pkg fiber --output res_interface_gen.go --not-exported true --iface-comment "Res"
type DefaultRes struct {
ctx *DefaultCtx
}
func (r *DefaultRes) Append(field string, values ...string) {
r.ctx.Append(field, values...)
}
func (r *DefaultRes) Attachment(filename ...string) {
r.ctx.Attachment(filename...)
}
func (r *DefaultRes) AutoFormat(body any) error {
return r.ctx.AutoFormat(body)
}
func (r *DefaultRes) CBOR(body any, ctype ...string) error {
return r.ctx.CBOR(body, ctype...)
}
func (r *DefaultRes) ClearCookie(key ...string) {
r.ctx.ClearCookie(key...)
}
func (r *DefaultRes) Cookie(cookie *Cookie) {
r.ctx.Cookie(cookie)
}
func (r *DefaultRes) Download(file string, filename ...string) error {
return r.ctx.Download(file, filename...)
}
func (r *DefaultRes) Format(handlers ...ResFmt) error {
return r.ctx.Format(handlers...)
}
func (r *DefaultRes) Get(key string, defaultValue ...string) string {
return r.ctx.GetRespHeader(key, defaultValue...)
}
func (r *DefaultRes) JSON(body any, ctype ...string) error {
return r.ctx.JSON(body, ctype...)
}
func (r *DefaultRes) JSONP(data any, callback ...string) error {
return r.ctx.JSONP(data, callback...)
}
func (r *DefaultRes) Links(link ...string) {
r.ctx.Links(link...)
}
func (r *DefaultRes) Location(path string) {
r.ctx.Location(path)
}
func (r *DefaultRes) Render(name string, bind any, layouts ...string) error {
return r.ctx.Render(name, bind, layouts...)
}
func (r *DefaultRes) Send(body []byte) error {
return r.ctx.Send(body)
}
func (r *DefaultRes) SendFile(file string, config ...SendFile) error {
return r.ctx.SendFile(file, config...)
}
func (r *DefaultRes) SendStatus(status int) error {
return r.ctx.SendStatus(status)
}
func (r *DefaultRes) SendString(body string) error {
return r.ctx.SendString(body)
}
func (r *DefaultRes) SendStreamWriter(streamWriter func(*bufio.Writer)) error {
return r.ctx.SendStreamWriter(streamWriter)
}
func (r *DefaultRes) Set(key, val string) {
r.ctx.Set(key, val)
}
func (r *DefaultRes) Status(status int) Ctx {
return r.ctx.Status(status)
}
func (r *DefaultRes) Type(extension string, charset ...string) Ctx {
return r.ctx.Type(extension, charset...)
}
func (r *DefaultRes) Vary(fields ...string) {
r.ctx.Vary(fields...)
}
func (r *DefaultRes) Write(p []byte) (int, error) {
return r.ctx.Write(p)
}
func (r *DefaultRes) Writef(f string, a ...any) (int, error) {
return r.ctx.Writef(f, a...)
}
func (r *DefaultRes) WriteString(s string) (int, error) {
return r.ctx.WriteString(s)
}
func (r *DefaultRes) XML(data any) error {
return r.ctx.XML(data)
}

38
res_interface_gen.go Normal file
View File

@ -0,0 +1,38 @@
// Code generated by ifacemaker; DO NOT EDIT.
package fiber
import (
"bufio"
)
// Res
type Res interface {
Append(field string, values ...string)
Attachment(filename ...string)
AutoFormat(body any) error
CBOR(body any, ctype ...string) error
ClearCookie(key ...string)
Cookie(cookie *Cookie)
Download(file string, filename ...string) error
Format(handlers ...ResFmt) error
Get(key string, defaultValue ...string) string
JSON(body any, ctype ...string) error
JSONP(data any, callback ...string) error
Links(link ...string)
Location(path string)
Render(name string, bind any, layouts ...string) error
Send(body []byte) error
SendFile(file string, config ...SendFile) error
SendStatus(status int) error
SendString(body string) error
SendStreamWriter(streamWriter func(*bufio.Writer)) error
Set(key, val string)
Status(status int) Ctx
Type(extension string, charset ...string) Ctx
Vary(fields ...string)
Write(p []byte) (int, error)
Writef(f string, a ...any) (int, error)
WriteString(s string) (int, error)
XML(data any) error
}