Skip to content

Commit 690e339

Browse files
authored
Add support for registering handlers for 404 routes (#2217)
1 parent 9bf1e3c commit 690e339

File tree

6 files changed

+375
-18
lines changed

6 files changed

+375
-18
lines changed

echo.go

+13
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,8 @@ const (
183183
PROPFIND = "PROPFIND"
184184
// REPORT Method can be used to get information about a resource, see rfc 3253
185185
REPORT = "REPORT"
186+
// RouteNotFound is special method type for routes handling "route not found" (404) cases
187+
RouteNotFound = "echo_route_not_found"
186188
)
187189

188190
// Headers
@@ -480,6 +482,16 @@ func (e *Echo) TRACE(path string, h HandlerFunc, m ...MiddlewareFunc) *Route {
480482
return e.Add(http.MethodTrace, path, h, m...)
481483
}
482484

485+
// RouteNotFound registers a special-case route which is executed when no other route is found (i.e. HTTP 404 cases)
486+
// for current request URL.
487+
// Path supports static and named/any parameters just like other http method is defined. Generally path is ended with
488+
// wildcard/match-any character (`/*`, `/download/*` etc).
489+
//
490+
// Example: `e.RouteNotFound("/*", func(c echo.Context) error { return c.NoContent(http.StatusNotFound) })`
491+
func (e *Echo) RouteNotFound(path string, h HandlerFunc, m ...MiddlewareFunc) *Route {
492+
return e.Add(RouteNotFound, path, h, m...)
493+
}
494+
483495
// Any registers a new route for all HTTP methods and path with matching handler
484496
// in the router with optional route-level middleware.
485497
func (e *Echo) Any(path string, handler HandlerFunc, middleware ...MiddlewareFunc) []*Route {
@@ -515,6 +527,7 @@ func (e *Echo) File(path, file string, m ...MiddlewareFunc) *Route {
515527
func (e *Echo) add(host, method, path string, handler HandlerFunc, middleware ...MiddlewareFunc) *Route {
516528
name := handlerName(handler)
517529
router := e.findRouter(host)
530+
// FIXME: when handler+middleware are both nil ... make it behave like handler removal
518531
router.Add(method, path, func(c Context) error {
519532
h := applyMiddleware(handler, middleware...)
520533
return h(c)

echo_test.go

+64
Original file line numberDiff line numberDiff line change
@@ -766,6 +766,70 @@ func TestEchoNotFound(t *testing.T) {
766766
assert.Equal(t, http.StatusNotFound, rec.Code)
767767
}
768768

769+
func TestEcho_RouteNotFound(t *testing.T) {
770+
var testCases = []struct {
771+
name string
772+
whenURL string
773+
expectRoute interface{}
774+
expectCode int
775+
}{
776+
{
777+
name: "404, route to static not found handler /a/c/xx",
778+
whenURL: "/a/c/xx",
779+
expectRoute: "GET /a/c/xx",
780+
expectCode: http.StatusNotFound,
781+
},
782+
{
783+
name: "404, route to path param not found handler /a/:file",
784+
whenURL: "/a/echo.exe",
785+
expectRoute: "GET /a/:file",
786+
expectCode: http.StatusNotFound,
787+
},
788+
{
789+
name: "404, route to any not found handler /*",
790+
whenURL: "/b/echo.exe",
791+
expectRoute: "GET /*",
792+
expectCode: http.StatusNotFound,
793+
},
794+
{
795+
name: "200, route /a/c/df to /a/c/df",
796+
whenURL: "/a/c/df",
797+
expectRoute: "GET /a/c/df",
798+
expectCode: http.StatusOK,
799+
},
800+
}
801+
802+
for _, tc := range testCases {
803+
t.Run(tc.name, func(t *testing.T) {
804+
e := New()
805+
806+
okHandler := func(c Context) error {
807+
return c.String(http.StatusOK, c.Request().Method+" "+c.Path())
808+
}
809+
notFoundHandler := func(c Context) error {
810+
return c.String(http.StatusNotFound, c.Request().Method+" "+c.Path())
811+
}
812+
813+
e.GET("/", okHandler)
814+
e.GET("/a/c/df", okHandler)
815+
e.GET("/a/b*", okHandler)
816+
e.PUT("/*", okHandler)
817+
818+
e.RouteNotFound("/a/c/xx", notFoundHandler) // static
819+
e.RouteNotFound("/a/:file", notFoundHandler) // param
820+
e.RouteNotFound("/*", notFoundHandler) // any
821+
822+
req := httptest.NewRequest(http.MethodGet, tc.whenURL, nil)
823+
rec := httptest.NewRecorder()
824+
825+
e.ServeHTTP(rec, req)
826+
827+
assert.Equal(t, tc.expectCode, rec.Code)
828+
assert.Equal(t, tc.expectRoute, rec.Body.String())
829+
})
830+
}
831+
}
832+
769833
func TestEchoMethodNotAllowed(t *testing.T) {
770834
e := New()
771835

group.go

+7
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,13 @@ func (g *Group) File(path, file string) {
107107
g.file(path, file, g.GET)
108108
}
109109

110+
// RouteNotFound implements `Echo#RouteNotFound()` for sub-routes within the Group.
111+
//
112+
// Example: `g.RouteNotFound("/*", func(c echo.Context) error { return c.NoContent(http.StatusNotFound) })`
113+
func (g *Group) RouteNotFound(path string, h HandlerFunc, m ...MiddlewareFunc) *Route {
114+
return g.Add(RouteNotFound, path, h, m...)
115+
}
116+
110117
// Add implements `Echo#Add()` for sub-routes within the Group.
111118
func (g *Group) Add(method, path string, handler HandlerFunc, middleware ...MiddlewareFunc) *Route {
112119
// Combine into a new slice to avoid accidentally passing the same slice for

group_test.go

+65
Original file line numberDiff line numberDiff line change
@@ -119,3 +119,68 @@ func TestGroupRouteMiddlewareWithMatchAny(t *testing.T) {
119119
assert.Equal(t, "/*", m)
120120

121121
}
122+
123+
func TestGroup_RouteNotFound(t *testing.T) {
124+
var testCases = []struct {
125+
name string
126+
whenURL string
127+
expectRoute interface{}
128+
expectCode int
129+
}{
130+
{
131+
name: "404, route to static not found handler /group/a/c/xx",
132+
whenURL: "/group/a/c/xx",
133+
expectRoute: "GET /group/a/c/xx",
134+
expectCode: http.StatusNotFound,
135+
},
136+
{
137+
name: "404, route to path param not found handler /group/a/:file",
138+
whenURL: "/group/a/echo.exe",
139+
expectRoute: "GET /group/a/:file",
140+
expectCode: http.StatusNotFound,
141+
},
142+
{
143+
name: "404, route to any not found handler /group/*",
144+
whenURL: "/group/b/echo.exe",
145+
expectRoute: "GET /group/*",
146+
expectCode: http.StatusNotFound,
147+
},
148+
{
149+
name: "200, route /group/a/c/df to /group/a/c/df",
150+
whenURL: "/group/a/c/df",
151+
expectRoute: "GET /group/a/c/df",
152+
expectCode: http.StatusOK,
153+
},
154+
}
155+
156+
for _, tc := range testCases {
157+
t.Run(tc.name, func(t *testing.T) {
158+
e := New()
159+
g := e.Group("/group")
160+
161+
okHandler := func(c Context) error {
162+
return c.String(http.StatusOK, c.Request().Method+" "+c.Path())
163+
}
164+
notFoundHandler := func(c Context) error {
165+
return c.String(http.StatusNotFound, c.Request().Method+" "+c.Path())
166+
}
167+
168+
g.GET("/", okHandler)
169+
g.GET("/a/c/df", okHandler)
170+
g.GET("/a/b*", okHandler)
171+
g.PUT("/*", okHandler)
172+
173+
g.RouteNotFound("/a/c/xx", notFoundHandler) // static
174+
g.RouteNotFound("/a/:file", notFoundHandler) // param
175+
g.RouteNotFound("/*", notFoundHandler) // any
176+
177+
req := httptest.NewRequest(http.MethodGet, tc.whenURL, nil)
178+
rec := httptest.NewRecorder()
179+
180+
e.ServeHTTP(rec, req)
181+
182+
assert.Equal(t, tc.expectCode, rec.Code)
183+
assert.Equal(t, tc.expectRoute, rec.Body.String())
184+
})
185+
}
186+
}

router.go

+41-18
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ type (
2828
isLeaf bool
2929
// isHandler indicates that node has at least one handler registered to it
3030
isHandler bool
31+
32+
// notFoundHandler is handler registered with RouteNotFound method and is executed for 404 cases
33+
notFoundHandler *routeMethod
3134
}
3235
kind uint8
3336
children []*node
@@ -73,6 +76,7 @@ func (m *routeMethods) isHandler() bool {
7376
m.put != nil ||
7477
m.trace != nil ||
7578
m.report != nil
79+
// RouteNotFound/404 is not considered as a handler
7680
}
7781

7882
func (m *routeMethods) updateAllowHeader() {
@@ -382,6 +386,9 @@ func (n *node) addMethod(method string, h *routeMethod) {
382386
n.methods.trace = h
383387
case REPORT:
384388
n.methods.report = h
389+
case RouteNotFound:
390+
n.notFoundHandler = h
391+
return // RouteNotFound/404 is not considered as a handler so no further logic needs to be executed
385392
}
386393

387394
n.methods.updateAllowHeader()
@@ -412,7 +419,7 @@ func (n *node) findMethod(method string) *routeMethod {
412419
return n.methods.trace
413420
case REPORT:
414421
return n.methods.report
415-
default:
422+
default: // RouteNotFound/404 is not considered as a handler
416423
return nil
417424
}
418425
}
@@ -515,7 +522,7 @@ func (r *Router) Find(method, path string, c Context) {
515522
// No matching prefix, let's backtrack to the first possible alternative node of the decision path
516523
nk, ok := backtrackToNextNodeKind(staticKind)
517524
if !ok {
518-
return // No other possibilities on the decision path
525+
return // No other possibilities on the decision path, handler will be whatever context is reset to.
519526
} else if nk == paramKind {
520527
goto Param
521528
// NOTE: this case (backtracking from static node to previous any node) can not happen by current any matching logic. Any node is end of search currently
@@ -531,15 +538,21 @@ func (r *Router) Find(method, path string, c Context) {
531538
search = search[lcpLen:]
532539
searchIndex = searchIndex + lcpLen
533540

534-
// Finish routing if no remaining search and we are on a node with handler and matching method type
535-
if search == "" && currentNode.isHandler {
536-
// check if current node has handler registered for http method we are looking for. we store currentNode as
537-
// best matching in case we do no find no more routes matching this path+method
538-
if previousBestMatchNode == nil {
539-
previousBestMatchNode = currentNode
540-
}
541-
if h := currentNode.findMethod(method); h != nil {
542-
matchedRouteMethod = h
541+
// Finish routing if is no request path remaining to search
542+
if search == "" {
543+
// in case of node that is handler we have exact method type match or something for 405 to use
544+
if currentNode.isHandler {
545+
// check if current node has handler registered for http method we are looking for. we store currentNode as
546+
// best matching in case we do no find no more routes matching this path+method
547+
if previousBestMatchNode == nil {
548+
previousBestMatchNode = currentNode
549+
}
550+
if h := currentNode.findMethod(method); h != nil {
551+
matchedRouteMethod = h
552+
break
553+
}
554+
} else if currentNode.notFoundHandler != nil {
555+
matchedRouteMethod = currentNode.notFoundHandler
543556
break
544557
}
545558
}
@@ -559,7 +572,8 @@ func (r *Router) Find(method, path string, c Context) {
559572
i := 0
560573
l := len(search)
561574
if currentNode.isLeaf {
562-
// when param node does not have any children then param node should act similarly to any node - consider all remaining search as match
575+
// when param node does not have any children (path param is last piece of route path) then param node should
576+
// act similarly to any node - consider all remaining search as match
563577
i = l
564578
} else {
565579
for ; i < l && search[i] != '/'; i++ {
@@ -585,13 +599,16 @@ func (r *Router) Find(method, path string, c Context) {
585599
searchIndex += +len(search)
586600
search = ""
587601

588-
// check if current node has handler registered for http method we are looking for. we store currentNode as
589-
// best matching in case we do no find no more routes matching this path+method
602+
if h := currentNode.findMethod(method); h != nil {
603+
matchedRouteMethod = h
604+
break
605+
}
606+
// we store currentNode as best matching in case we do not find more routes matching this path+method. Needed for 405
590607
if previousBestMatchNode == nil {
591608
previousBestMatchNode = currentNode
592609
}
593-
if h := currentNode.findMethod(method); h != nil {
594-
matchedRouteMethod = h
610+
if currentNode.notFoundHandler != nil {
611+
matchedRouteMethod = currentNode.notFoundHandler
595612
break
596613
}
597614
}
@@ -614,12 +631,14 @@ func (r *Router) Find(method, path string, c Context) {
614631
return // nothing matched at all
615632
}
616633

634+
// matchedHandler could be method+path handler that we matched or notFoundHandler from node with matching path
635+
// user provided not found (404) handler has priority over generic method not found (405) handler or global 404 handler
617636
var rPath string
618637
var rPNames []string
619638
if matchedRouteMethod != nil {
620-
ctx.handler = matchedRouteMethod.handler
621639
rPath = matchedRouteMethod.ppath
622640
rPNames = matchedRouteMethod.pnames
641+
ctx.handler = matchedRouteMethod.handler
623642
} else {
624643
// use previous match as basis. although we have no matching handler we have path match.
625644
// so we can send http.StatusMethodNotAllowed (405) instead of http.StatusNotFound (404)
@@ -628,7 +647,11 @@ func (r *Router) Find(method, path string, c Context) {
628647
rPath = currentNode.originalPath
629648
rPNames = nil // no params here
630649
ctx.handler = NotFoundHandler
631-
if currentNode.isHandler {
650+
if currentNode.notFoundHandler != nil {
651+
rPath = currentNode.notFoundHandler.ppath
652+
rPNames = currentNode.notFoundHandler.pnames
653+
ctx.handler = currentNode.notFoundHandler.handler
654+
} else if currentNode.isHandler {
632655
ctx.Set(ContextKeyHeaderAllow, currentNode.methods.allowHeader)
633656
ctx.handler = MethodNotAllowedHandler
634657
if method == http.MethodOptions {

0 commit comments

Comments
 (0)