Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
17 changes: 17 additions & 0 deletions app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,23 @@ func Test_App_MethodNotAllowed(t *testing.T) {
require.Equal(t, "GET, HEAD, POST, OPTIONS", resp.Header.Get(HeaderAllow))
}

func Test_App_QueryMethod_AllowHeader(t *testing.T) {
t.Parallel()
app := New()
app.Query("/", testEmptyHandler)

// OPTIONS auto-response advertises QUERY in the Allow header.
resp, err := app.Test(httptest.NewRequest(MethodOptions, "/", http.NoBody))
require.NoError(t, err)
require.Contains(t, resp.Header.Get(HeaderAllow), MethodQuery)

// A non-registered method yields 405 with QUERY listed in Allow.
resp, err = app.Test(httptest.NewRequest(MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, StatusMethodNotAllowed, resp.StatusCode)
require.Contains(t, resp.Header.Get(HeaderAllow), MethodQuery)
}

func Test_App_RegisterNetHTTPHandler(t *testing.T) {
t.Parallel()

Expand Down
12 changes: 12 additions & 0 deletions client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,13 @@ func (c *Client) Patch(url string, cfg ...Config) (*Response, error) {
return req.Patch(url)
}

// Query sends a QUERY request to the specified URL, similar to axios.
func (c *Client) Query(url string, cfg ...Config) (*Response, error) {
req := AcquireRequest().SetClient(c)
setConfigToRequest(req, cfg...)
return req.Query(url)
}

// Custom sends a request with a custom method to the specified URL, similar to axios.
func (c *Client) Custom(url, method string, cfg ...Config) (*Response, error) {
req := AcquireRequest().SetClient(c)
Expand Down Expand Up @@ -874,3 +881,8 @@ func Options(url string, cfg ...Config) (*Response, error) {
func Patch(url string, cfg ...Config) (*Response, error) {
return C().Patch(url, cfg...)
}

// Query sends a QUERY request using the default client.
func Query(url string, cfg ...Config) (*Response, error) {
return C().Query(url, cfg...)
}
50 changes: 50 additions & 0 deletions client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1227,6 +1227,56 @@ func Test_Patch(t *testing.T) {
})
}

func Test_Query(t *testing.T) {
t.Parallel()

setupApp := func() (*fiber.App, string) {
app, addr := startTestServerWithPort(t, func(app *fiber.App) {
app.Query("/", func(c fiber.Ctx) error {
return c.Send(c.Body())
})
})

return app, addr
}

t.Run("global query function", func(t *testing.T) {
t.Parallel()

app, addr := setupApp()
defer func() {
require.NoError(t, app.Shutdown())
}()

time.Sleep(1 * time.Second)

for range 5 {
resp, err := Query("http://"+addr, Config{Body: "query body"})

require.NoError(t, err)
require.Equal(t, fiber.StatusOK, resp.StatusCode())
require.Equal(t, "query body", resp.String())
}
})

t.Run("client query", func(t *testing.T) {
t.Parallel()

app, addr := setupApp()
defer func() {
require.NoError(t, app.Shutdown())
}()

for range 5 {
resp, err := New().Query("http://"+addr, Config{Body: "query body"})

require.NoError(t, err)
require.Equal(t, fiber.StatusOK, resp.StatusCode())
require.Equal(t, "query body", resp.String())
}
})
}

func Test_Client_UserAgent(t *testing.T) {
t.Parallel()

Expand Down
4 changes: 2 additions & 2 deletions client/core.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,13 +113,13 @@ func (c *core) execFunc() (*Response, error) {
if cfg != nil {
// Use an exponential backoff retry strategy.
err = retry.NewExponentialBackoff(*cfg).Retry(func() error {
if c.req.maxRedirects > 0 && (string(reqv.Header.Method()) == fiber.MethodGet || string(reqv.Header.Method()) == fiber.MethodHead) {
if c.req.maxRedirects > 0 && (string(reqv.Header.Method()) == fiber.MethodGet || string(reqv.Header.Method()) == fiber.MethodHead || string(reqv.Header.Method()) == fiber.MethodQuery) {
return c.client.DoRedirects(reqv, respv, c.req.maxRedirects)
}
return c.client.Do(reqv, respv)
})
} else {
if c.req.maxRedirects > 0 && (string(reqv.Header.Method()) == fiber.MethodGet || string(reqv.Header.Method()) == fiber.MethodHead) {
if c.req.maxRedirects > 0 && (string(reqv.Header.Method()) == fiber.MethodGet || string(reqv.Header.Method()) == fiber.MethodHead || string(reqv.Header.Method()) == fiber.MethodQuery) {
err = c.client.DoRedirects(reqv, respv, c.req.maxRedirects)
} else {
err = c.client.Do(reqv, respv)
Expand Down
31 changes: 31 additions & 0 deletions client/request_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1622,6 +1622,37 @@ func Test_Request_MaxRedirects(t *testing.T) {
})
}

func Test_Request_Query_Redirect(t *testing.T) {
t.Parallel()

ln := fasthttputil.NewInmemoryListener()

app := fiber.New()
// 308 preserves method and body, so QUERY must follow it and keep its body.
app.Query("/start", func(c fiber.Ctx) error {
return c.Redirect().Status(fiber.StatusPermanentRedirect).To("/end")
})
app.Query("/end", func(c fiber.Ctx) error {
return c.Send(c.Body())
})

go func() { assert.NoError(t, app.Listener(ln, fiber.ListenConfig{DisableStartupMessage: true})) }()

client := New().SetDial(func(_ string) (net.Conn, error) { return ln.Dial() })

resp, err := AcquireRequest().
SetClient(client).
SetMaxRedirects(1).
SetRawBody([]byte("query body")).
Query("http://example.com/start")

require.NoError(t, err)
require.Equal(t, fiber.StatusOK, resp.StatusCode())
require.Equal(t, "query body", resp.String())

resp.Close()
}

func Test_SetValWithStruct(t *testing.T) {
t.Parallel()

Expand Down
1 change: 1 addition & 0 deletions docs/api/app.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ type Register interface {
Options(handler any, handlers ...any) Register
Trace(handler any, handlers ...any) Register
Patch(handler any, handlers ...any) Register
Query(handler any, handlers ...any) Register

Add(methods []string, handler any, handlers ...any) Register

Expand Down
16 changes: 16 additions & 0 deletions docs/client/rest.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,14 @@ Sends a PATCH request.
func (c *Client) Patch(url string, cfg ...Config) (*Response, error)
```

### Query

Sends a QUERY request.

```go title="Signature"
func (c *Client) Query(url string, cfg ...Config) (*Response, error)
```

### Delete

Sends a DELETE request.
Expand Down Expand Up @@ -893,6 +901,14 @@ Patch is a convenience method that sends a PATCH request using the `defaultClien
func Patch(url string, cfg ...Config) (*Response, error)
```

### Query

Query is a convenience method that sends a QUERY request using the `defaultClient`.

```go title="Signature"
func Query(url string, cfg ...Config) (*Response, error)
```

### Delete

Delete is a convenience method that sends a DELETE request using the `defaultClient`.
Expand Down
4 changes: 2 additions & 2 deletions docs/middleware/cache.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,9 @@ By default, cache keys include:

This prevents common collisions from path-only keys (for example, `/?id=1` vs `/?id=2`) while keeping fragmentation bounded.

The middleware **does not include request body/form values in the default cache key**.
The middleware **does not include request body/form values in the default cache key**, except for `QUERY` requests: per [RFC 10008](https://www.rfc-editor.org/rfc/rfc10008.html), when `QUERY` is enabled via `Methods` the default key generator incorporates the request body so different bodies on the same URL get distinct keys.

Cache lookup/storage is applied only for `GET` and `HEAD` requests by default. Other HTTP methods bypass the cache middleware. You can change this via the `Methods` config field.
Cache lookup/storage is applied only for `GET` and `HEAD` requests by default. Other HTTP methods bypass the cache middleware. You can change this via the `Methods` config field (for example, adding `fiber.MethodQuery`). If you supply a custom `KeyGenerator` and enable a body-bearing method such as `QUERY`, make sure it incorporates `c.Request().Body()`, otherwise requests with the same URL but different bodies will collide.

If a response sets `Vary`, request lookup/storage is also partitioned by those header values unless `DisableVaryHeaders` is `true`. Responses with `Vary: *` remain uncacheable.

Expand Down
3 changes: 2 additions & 1 deletion docs/middleware/cors.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ panic: [CORS] Configuration error: When 'AllowCredentials' is set to true, 'Allo
|:---------------------|:----------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:----------------------------------------|
| AllowCredentials | `bool` | AllowCredentials indicates whether or not the response to the request can be exposed when the credentials flag is true. When used as part of a response to a preflight request, this indicates whether or not the actual request can be made using credentials. Note: If true, AllowOrigins cannot be set to a wildcard (`"*"`) to prevent security vulnerabilities. | `false` |
| AllowHeaders | `[]string` | AllowHeaders defines a list of request headers that can be used when making the actual request. This is in response to a preflight request. | `[]` |
| AllowMethods | `[]string` | AllowMethods defines a list of methods allowed when accessing the resource. This is used in response to a preflight request. | `"GET, POST, HEAD, PUT, DELETE, PATCH"` |
| AllowMethods | `[]string` | AllowMethods defines a list of methods allowed when accessing the resource. This is used in response to a preflight request. | `"GET, POST, HEAD, PUT, DELETE, PATCH, QUERY"` |
Comment thread
coderabbitai[bot] marked this conversation as resolved.
| AllowOrigins | `[]string` | AllowOrigins defines a list of origins that may access the resource. This supports subdomain matching, so you can use a value like "https://*.example.com" to allow any subdomain of example.com to submit requests. If the special wildcard `"*"` is present in the list, all origins will be allowed. | `["*"]` |
| AllowOriginsFunc | `func(origin string) bool` | `AllowOriginsFunc` is a function that dynamically determines whether to allow a request based on its origin. If this function returns `true`, the 'Access-Control-Allow-Origin' response header will be set to the request's 'origin' header. This function is only used if the request's origin doesn't match any origin in `AllowOrigins`. | `nil` |
| AllowPrivateNetwork | `bool` | Indicates whether the `Access-Control-Allow-Private-Network` response header should be set to `true`, allowing requests from private networks. This aligns with modern security practices for web applications interacting with private networks. | `false` |
Expand Down Expand Up @@ -142,6 +142,7 @@ var ConfigDefault = Config{
fiber.MethodPut,
fiber.MethodDelete,
fiber.MethodPatch,
fiber.MethodQuery,
},
AllowHeaders: []string{},
AllowCredentials: false,
Expand Down
2 changes: 1 addition & 1 deletion docs/middleware/csrf.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ id: csrf

# CSRF

The CSRF middleware protects against [Cross-Site Request Forgery](https://en.wikipedia.org/wiki/Cross-site_request_forgery) attacks by validating tokens on unsafe HTTP methods such as POST, PUT, and DELETE. It responds with 403 Forbidden when validation fails.
The CSRF middleware protects against [Cross-Site Request Forgery](https://en.wikipedia.org/wiki/Cross-site_request_forgery) attacks by validating tokens on unsafe HTTP methods such as POST, PUT, and DELETE. It responds with 403 Forbidden when validation fails. Safe methods (`GET`, `HEAD`, `OPTIONS`, `TRACE`, `QUERY`) are not validated; note that `QUERY` is classified as safe per RFC 10008, so do not perform state changes in `QUERY` handlers.

## Table of Contents

Expand Down
2 changes: 1 addition & 1 deletion docs/middleware/earlydata.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Enabling early data in a reverse proxy (for example, `ssl_early_data on;` in ngi
- [datatracker](https://datatracker.ietf.org/doc/html/rfc8446#section-8)
- [trailofbits](https://blog.trailofbits.com/2019/03/25/what-application-developers-need-to-know-about-tls-early-data-0rtt)

By default, the middleware permits early data only for safe methods (`GET`, `HEAD`, `OPTIONS`, `TRACE`) and rejects other requests before your handler runs. Override this behavior with the `AllowEarlyData` option.
By default, the middleware permits early data only for safe methods (`GET`, `HEAD`, `OPTIONS`, `TRACE`, `QUERY`) and rejects other requests before your handler runs. Override this behavior with the `AllowEarlyData` option.

## Signatures

Expand Down
2 changes: 1 addition & 1 deletion docs/middleware/idempotency.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Refer to [IETF RFC 7231 §4.2.2](https://tools.ietf.org/html/rfc7231#section-4.2

## HTTP Method Categories

* **Safe Methods** (do not modify server state): `GET`, `HEAD`, `OPTIONS`, `TRACE`
* **Safe Methods** (do not modify server state): `GET`, `HEAD`, `OPTIONS`, `TRACE`, `QUERY`
* **Idempotent Methods** (identical requests have the same effect as a single one): all safe methods **plus** `PUT` and `DELETE`

> According to the RFC, safe methods never change server state, while idempotent methods may change state but remain safe to repeat.
Expand Down
15 changes: 15 additions & 0 deletions docs/whats_new.md
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,21 @@ app.Get("/health", handler) // HEAD /health now returns 405 unless you add it ma

Auto-generated `HEAD` routes appear in tooling such as `app.Stack()` and cover the same routing scenarios as their `GET` counterparts, including groups, mounted apps, dynamic parameters, and static file handlers.

### QUERY method (RFC 10008)

Fiber now supports the HTTP `QUERY` method ([RFC 10008](https://www.rfc-editor.org/rfc/rfc10008.html)) as a first-class verb. `QUERY` is safe and idempotent like `GET`, but allows a request body for complex queries.

```go title="Register a QUERY route"
app := fiber.New()

app.Query("/search", func(c fiber.Ctx) error {
// QUERY carries a request body, unlike GET.
return c.Send(c.Body())
})
```

`Query` is available on `App`, `Group`, the route-chaining `Register` interface, and domain routers, plus the HTTP client (`Request.Query`, `Client.Query`, and the package-level `Query`). `fiber.IsMethodSafe` and `fiber.IsMethodIdempotent` both return `true` for `QUERY`, so middleware that keys off method safety (CSRF, idempotency, early data) treats it like the other safe methods. The cache middleware can cache `QUERY` responses when `QUERY` is added to `Config.Methods`; the default key generator folds the request body into the cache key so different bodies on the same URL do not collide.

### Middleware registration

We have aligned our method for middlewares closer to [`Express`](https://expressjs.com/en/api.html#app.use) and now also support the [`Use`](./api/app#use) of multiple prefixes.
Expand Down
28 changes: 28 additions & 0 deletions helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1824,3 +1824,31 @@ func TestValueFromContext(t *testing.T) {
require.Empty(t, value)
})
}

func Test_IsMethodSafe(t *testing.T) {
t.Parallel()

safeMethods := []string{MethodGet, MethodHead, MethodOptions, MethodTrace, MethodQuery}
unsafeMethods := []string{MethodPost, MethodPut, MethodPatch, MethodDelete, MethodConnect}

for _, m := range safeMethods {
require.True(t, IsMethodSafe(m), "%s should be safe", m)
}
for _, m := range unsafeMethods {
require.False(t, IsMethodSafe(m), "%s should not be safe", m)
}
}

func Test_IsMethodIdempotent(t *testing.T) {
t.Parallel()

idempotent := []string{MethodGet, MethodHead, MethodOptions, MethodTrace, MethodQuery, MethodPut, MethodDelete}
notIdempotent := []string{MethodPost, MethodPatch, MethodConnect}

for _, m := range idempotent {
require.True(t, IsMethodIdempotent(m), "%s should be idempotent", m)
}
for _, m := range notIdempotent {
require.False(t, IsMethodIdempotent(m), "%s should not be idempotent", m)
}
}
10 changes: 5 additions & 5 deletions middleware/cache/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -1291,12 +1291,12 @@ func defaultKeyGenerator(c fiber.Ctx, cfg *Config) string {
}

if c.Method() == fiber.MethodQuery {
// RFC 10008 only requires the cache key to incorporate the request content,
// not that it be hashed. boundKeySegment keeps small bodies verbatim (no
// hashing on the hot path) and hashes only bodies larger than
// maxKeyDimensionSegmentLength, which still bounds the key length.
// RFC 10008: incorporate the request body so different QUERY bodies on the
// same URL get distinct keys. Escape delimiters like every other dimension
// (escapeKeyDelimiters has a no-alloc fast path), then bound the segment so
// large bodies are hashed and the key length stays capped.
buf = append(buf, '|', 'b', '=')
buf = append(buf, boundKeySegment(utils.UnsafeString(c.Request().Body()))...)
buf = appendBoundKeySegment(buf, escapeKeyDelimiters(utils.UnsafeString(c.Request().Body())))
}

result := string(buf)
Expand Down
20 changes: 18 additions & 2 deletions middleware/cache/keygen_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ type keygenCase struct {
headers map[string]string
name string
uri string
method string
body string
cookie string
want string
keyCooks []string
Expand All @@ -34,14 +36,28 @@ func keygenCorpus() []keygenCase {
{name: "header_val_delims", uri: "/p", headers: map[string]string{"Accept": "a|b:c"}, want: "/|q=|h=Accept:a\\pb\\cc|Accept-Encoding:|Accept-Language:"},
{name: "empty_query", uri: "/p?", want: "/|q=|h=Accept:|Accept-Encoding:|Accept-Language:"},
{name: "many_params", uri: "/p?" + strings.Repeat("k=v&", 200) + "z=1", want: "/|q=sha256:f8f7166c8aec35092b4c6f66a895ec9f302746c6310aa0dbfde45cbd30aa1829|h=Accept:|Accept-Encoding:|Accept-Language:"},
{name: "query_empty_body", uri: "/q", method: fiber.MethodQuery, want: "/|q=|h=Accept:|Accept-Encoding:|Accept-Language:|b="},
{name: "query_body", uri: "/q", method: fiber.MethodQuery, body: "foo=bar", want: "/|q=|h=Accept:|Accept-Encoding:|Accept-Language:|b=foo=bar"},
{name: "query_body_delims", uri: "/q", method: fiber.MethodQuery, body: "a|b:c", want: "/|q=|h=Accept:|Accept-Encoding:|Accept-Language:|b=a\\pb\\cc"},
{name: "query_with_querystring", uri: "/q?x=1", method: fiber.MethodQuery, body: "foo=bar", want: "/|q=x=1|h=Accept:|Accept-Encoding:|Accept-Language:|b=foo=bar"},
}
}

func buildKeygenCtx(tc *keygenCase) (fiber.Ctx, *Config) {
app := fiber.New()
c := app.AcquireCtx(&fasthttp.RequestCtx{})
// Method must be set before AcquireCtx (fiber caches it); URI/body/headers are
// read live from the request afterwards.
fctx := &fasthttp.RequestCtx{}
method := tc.method
if method == "" {
method = fiber.MethodGet
}
fctx.Request.Header.SetMethod(method)
c := app.AcquireCtx(fctx)
c.Request().SetRequestURI(tc.uri)
c.Request().Header.SetMethod(fiber.MethodGet)
if tc.body != "" {
c.Request().SetBody([]byte(tc.body))
}
for k, v := range tc.headers {
c.Request().Header.Set(k, v)
}
Expand Down
4 changes: 2 additions & 2 deletions middleware/csrf/csrf_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ func Test_CSRF(t *testing.T) {
h := app.Handler()
ctx := &fasthttp.RequestCtx{}

methods := [4]string{fiber.MethodGet, fiber.MethodHead, fiber.MethodOptions, fiber.MethodTrace}
methods := [5]string{fiber.MethodGet, fiber.MethodHead, fiber.MethodOptions, fiber.MethodTrace, fiber.MethodQuery}

for _, method := range methods {
// Generate CSRF token
Expand Down Expand Up @@ -297,7 +297,7 @@ func Test_CSRF_WithSession(t *testing.T) {

h := app.Handler()

methods := [4]string{fiber.MethodGet, fiber.MethodHead, fiber.MethodOptions, fiber.MethodTrace}
methods := [5]string{fiber.MethodGet, fiber.MethodHead, fiber.MethodOptions, fiber.MethodTrace, fiber.MethodQuery}

for _, method := range methods {
// Generate CSRF token
Expand Down
Loading
Loading