Skip to content

Commit 4010002

Browse files
committed
feat: add SkipMiddlewareOnNotFound to short-circuit 404/405 requests
1 parent 1753170 commit 4010002

File tree

4 files changed

+127
-33
lines changed

4 files changed

+127
-33
lines changed

binder_generic.go

Lines changed: 39 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,11 @@ const (
4949
// It returns the typed value and an error if binding fails. Returns ErrNonExistentKey if parameter not found.
5050
//
5151
// Empty String Handling:
52-
// If the parameter exists but has an empty value, the zero value of type T is returned
53-
// with no error. For example, a path parameter with value "" returns (0, nil) for int types.
54-
// This differs from standard library behavior where parsing empty strings returns errors.
55-
// To treat empty values as errors, validate the result separately or check the raw value.
52+
//
53+
// If the parameter exists but has an empty value, the zero value of type T is returned
54+
// with no error. For example, a path parameter with value "" returns (0, nil) for int types.
55+
// This differs from standard library behavior where parsing empty strings returns errors.
56+
// To treat empty values as errors, validate the result separately or check the raw value.
5657
//
5758
// See ParseValue for supported types and options
5859
func PathParam[T any](c *Context, paramName string, opts ...any) (T, error) {
@@ -74,10 +75,11 @@ func PathParam[T any](c *Context, paramName string, opts ...any) (T, error) {
7475
// Returns an error only if parsing fails (e.g., "abc" for int type).
7576
//
7677
// Example:
77-
// id, err := echo.PathParamOr[int](c, "id", 0)
78-
// // If "id" is missing: returns (0, nil)
79-
// // If "id" is "123": returns (123, nil)
80-
// // If "id" is "abc": returns (0, BindingError)
78+
//
79+
// id, err := echo.PathParamOr[int](c, "id", 0)
80+
// // If "id" is missing: returns (0, nil)
81+
// // If "id" is "123": returns (123, nil)
82+
// // If "id" is "abc": returns (0, BindingError)
8183
//
8284
// See ParseValue for supported types and options
8385
func PathParamOr[T any](c *Context, paramName string, defaultValue T, opts ...any) (T, error) {
@@ -97,10 +99,11 @@ func PathParamOr[T any](c *Context, paramName string, defaultValue T, opts ...an
9799
// It returns the typed value and an error if binding fails. Returns ErrNonExistentKey if parameter not found.
98100
//
99101
// Empty String Handling:
100-
// If the parameter exists but has an empty value (?key=), the zero value of type T is returned
101-
// with no error. For example, "?count=" returns (0, nil) for int types.
102-
// This differs from standard library behavior where parsing empty strings returns errors.
103-
// To treat empty values as errors, validate the result separately or check the raw value.
102+
//
103+
// If the parameter exists but has an empty value (?key=), the zero value of type T is returned
104+
// with no error. For example, "?count=" returns (0, nil) for int types.
105+
// This differs from standard library behavior where parsing empty strings returns errors.
106+
// To treat empty values as errors, validate the result separately or check the raw value.
104107
//
105108
// Behavior Summary:
106109
// - Missing key (?other=value): returns (zero, ErrNonExistentKey)
@@ -131,10 +134,11 @@ func QueryParam[T any](c *Context, key string, opts ...any) (T, error) {
131134
// Returns an error only if parsing fails (e.g., "abc" for int type).
132135
//
133136
// Example:
134-
// page, err := echo.QueryParamOr[int](c, "page", 1)
135-
// // If "page" is missing: returns (1, nil)
136-
// // If "page" is "5": returns (5, nil)
137-
// // If "page" is "abc": returns (1, BindingError)
137+
//
138+
// page, err := echo.QueryParamOr[int](c, "page", 1)
139+
// // If "page" is missing: returns (1, nil)
140+
// // If "page" is "5": returns (5, nil)
141+
// // If "page" is "abc": returns (1, BindingError)
138142
//
139143
// See ParseValue for supported types and options
140144
func QueryParamOr[T any](c *Context, key string, defaultValue T, opts ...any) (T, error) {
@@ -175,10 +179,11 @@ func QueryParams[T any](c *Context, key string, opts ...any) ([]T, error) {
175179
// Returns an error only if parsing any value fails.
176180
//
177181
// Example:
178-
// ids, err := echo.QueryParamsOr[int](c, "ids", []int{})
179-
// // If "ids" is missing: returns ([], nil)
180-
// // If "ids" is "1&ids=2": returns ([1, 2], nil)
181-
// // If "ids" contains "abc": returns ([], BindingError)
182+
//
183+
// ids, err := echo.QueryParamsOr[int](c, "ids", []int{})
184+
// // If "ids" is missing: returns ([], nil)
185+
// // If "ids" is "1&ids=2": returns ([1, 2], nil)
186+
// // If "ids" contains "abc": returns ([], BindingError)
182187
//
183188
// See ParseValues for supported types and options
184189
func QueryParamsOr[T any](c *Context, key string, defaultValue []T, opts ...any) ([]T, error) {
@@ -198,10 +203,11 @@ func QueryParamsOr[T any](c *Context, key string, defaultValue []T, opts ...any)
198203
// It returns the typed value and an error if binding fails. Returns ErrNonExistentKey if parameter not found.
199204
//
200205
// Empty String Handling:
201-
// If the form field exists but has an empty value, the zero value of type T is returned
202-
// with no error. For example, an empty form field returns (0, nil) for int types.
203-
// This differs from standard library behavior where parsing empty strings returns errors.
204-
// To treat empty values as errors, validate the result separately or check the raw value.
206+
//
207+
// If the form field exists but has an empty value, the zero value of type T is returned
208+
// with no error. For example, an empty form field returns (0, nil) for int types.
209+
// This differs from standard library behavior where parsing empty strings returns errors.
210+
// To treat empty values as errors, validate the result separately or check the raw value.
205211
//
206212
// See ParseValue for supported types and options
207213
func FormValue[T any](c *Context, key string, opts ...any) (T, error) {
@@ -232,10 +238,11 @@ func FormValue[T any](c *Context, key string, opts ...any) (T, error) {
232238
// Returns an error only if parsing fails or form parsing errors occur.
233239
//
234240
// Example:
235-
// limit, err := echo.FormValueOr[int](c, "limit", 100)
236-
// // If "limit" is missing: returns (100, nil)
237-
// // If "limit" is "50": returns (50, nil)
238-
// // If "limit" is "abc": returns (100, BindingError)
241+
//
242+
// limit, err := echo.FormValueOr[int](c, "limit", 100)
243+
// // If "limit" is missing: returns (100, nil)
244+
// // If "limit" is "50": returns (50, nil)
245+
// // If "limit" is "abc": returns (100, BindingError)
239246
//
240247
// See ParseValue for supported types and options
241248
func FormValueOr[T any](c *Context, key string, defaultValue T, opts ...any) (T, error) {
@@ -284,9 +291,10 @@ func FormValues[T any](c *Context, key string, opts ...any) ([]T, error) {
284291
// Returns an error only if parsing any value fails or form parsing errors occur.
285292
//
286293
// Example:
287-
// tags, err := echo.FormValuesOr[string](c, "tags", []string{})
288-
// // If "tags" is missing: returns ([], nil)
289-
// // If form parsing fails: returns (nil, error)
294+
//
295+
// tags, err := echo.FormValuesOr[string](c, "tags", []string{})
296+
// // If "tags" is missing: returns ([], nil)
297+
// // If form parsing fails: returns (nil, error)
290298
//
291299
// See ParseValues for supported types and options
292300
func FormValuesOr[T any](c *Context, key string, defaultValue []T, opts ...any) ([]T, error) {

context.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,16 @@ func (c *Context) SetResponse(r http.ResponseWriter) {
146146
c.response = r
147147
}
148148

149+
// IsNotFound returns true if the route is not found otherwise false
150+
func (c *Context) IsNotFound() bool {
151+
return c.route == notFoundRouteInfo
152+
}
153+
154+
// IsMethodAllowed returns true if the method is not allowed otherwise false
155+
func (c *Context) IsMethodNotAllowed() bool {
156+
return c.route == methodNotAllowedRouteInfo
157+
}
158+
149159
// IsTLS returns true if HTTP connection is TLS otherwise false.
150160
func (c *Context) IsTLS() bool {
151161
return c.request.TLS != nil

echo.go

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,9 @@ type Echo struct {
9292

9393
// formParseMaxMemory is passed to Context for multipart form parsing (See http.Request.ParseMultipartForm)
9494
formParseMaxMemory int64
95+
96+
//SkipMiddlewareOnNotFound is a flag, when route is not found, echo prevents wasting compute resources
97+
SkipMiddlewareOnNotFound bool
9598
}
9699

97100
// JSONSerializer is the interface that encodes and decodes JSON to and from interfaces.
@@ -684,13 +687,38 @@ func (e *Echo) serveHTTP(w http.ResponseWriter, r *http.Request) {
684687
defer e.contextPool.Put(c)
685688

686689
c.Reset(r, w)
690+
687691
var h HandlerFunc
688692

689693
if e.premiddleware == nil {
690-
h = applyMiddleware(e.router.Route(c), e.middleware...)
694+
// --- THE FIX START ---
695+
// Perform routing immediately
696+
h = e.router.Route(c)
697+
698+
// Check if the global middleware chain for 404/405 should be skipped
699+
// We use c.InternalRouteInfo() to check if it's a "virtual" route (404/405)
700+
if e.SkipMiddlewareOnNotFound && (c.IsNotFound() || c.IsMethodNotAllowed()) {
701+
// Execute the 404/405 handler directly, skipping e.middleware...
702+
if err := h(c); err != nil {
703+
e.HTTPErrorHandler(c, err)
704+
}
705+
return
706+
}
707+
// Otherwise apply the middleware chain normally
708+
h = applyMiddleware(h, e.middleware...)
709+
// --- THE FIX END ---
691710
} else {
711+
// If premiddleware exists, we must wrap the routing logic inside a function
712+
// because premiddleware might change the URL (Rewrite/TrailingSlash)
692713
h = func(cc *Context) error {
693-
h1 := applyMiddleware(e.router.Route(cc), e.middleware...)
714+
rh := e.router.Route(cc)
715+
716+
// Apply logic inside the premiddleware-triggered chain
717+
if e.SkipMiddlewareOnNotFound && (cc.IsNotFound() || cc.IsMethodNotAllowed()) {
718+
return rh(cc)
719+
}
720+
721+
h1 := applyMiddleware(rh, e.middleware...)
694722
return h1(cc)
695723
}
696724
h = applyMiddleware(h, e.premiddleware...)

middleware_skip_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package echo
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"testing"
7+
)
8+
9+
// BenchmarkMiddleware404 compares performance when middleware is skipped on 404
10+
func BenchmarkMiddleware404(b *testing.B) {
11+
e := New()
12+
13+
// Simulate a "Heavy" middleware (e.g., Auth or Logging)
14+
e.Use(func(next HandlerFunc) HandlerFunc {
15+
return func(c *Context) error {
16+
c.Set("user_id", "12345") // Simulate some work
17+
return next(c)
18+
}
19+
})
20+
21+
e.GET("/exists", func(c *Context) error {
22+
return c.NoContent(http.StatusOK)
23+
})
24+
25+
// Case 1: Standard behavior (Middleware runs on 404)
26+
b.Run("Normal_404", func(b *testing.B) {
27+
e.SkipMiddlewareOnNotFound = false
28+
req := httptest.NewRequest(http.MethodGet, "/not-found", nil)
29+
w := httptest.NewRecorder()
30+
b.ReportAllocs()
31+
b.ResetTimer()
32+
for i := 0; i < b.N; i++ {
33+
e.ServeHTTP(w, req)
34+
}
35+
})
36+
37+
// Case 2: Optimized behavior (Middleware skipped on 404)
38+
b.Run("Optimized_404", func(b *testing.B) {
39+
e.SkipMiddlewareOnNotFound = true
40+
req := httptest.NewRequest(http.MethodGet, "/not-found", nil)
41+
w := httptest.NewRecorder()
42+
b.ReportAllocs()
43+
b.ResetTimer()
44+
for i := 0; i < b.N; i++ {
45+
e.ServeHTTP(w, req)
46+
}
47+
})
48+
}

0 commit comments

Comments
 (0)