diff --git a/group_test.go b/group_test.go index 279e5a2..63372da 100644 --- a/group_test.go +++ b/group_test.go @@ -100,11 +100,6 @@ func TestGroup_Add(t *testing.T) { c.JSON(http.StatusCreated, Map{"message": c.Request().Method}) }, []string{http.MethodGet, http.MethodPost}) - assert.Equal(t, 1, len(k.router.routes)) - assert.Equal(t, 2, len(k.router.routes[0].methods)) - assert.Equal(t, 0, len(k.router.routes[0].middlewares)) - assert.Equal(t, []string{http.MethodGet, http.MethodPost}, k.router.routes[0].methods) - testCases := []struct { req *http.Request res *httptest.ResponseRecorder diff --git a/kid.go b/kid.go index 4e4f65a..367758b 100644 --- a/kid.go +++ b/kid.go @@ -28,7 +28,7 @@ type ( // // It's a framework instance. Kid struct { - router Router + router Tree middlewares []MiddlewareFunc notFoundHandler HandlerFunc methodNotAllowedHandler HandlerFunc @@ -43,7 +43,7 @@ type ( // Version of Kid. const Version string = "0.1.0" -// allMethods is all of the HTTP methods. +// allMethods is a list of all HTTP methods. var allMethods = []string{ http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete, http.MethodHead, @@ -53,7 +53,7 @@ var allMethods = []string{ // New returns a new instance of Kid. func New() *Kid { kid := Kid{ - router: newRouter(), + router: newTree(), middlewares: make([]MiddlewareFunc, 0), notFoundHandler: defaultNotFoundHandler, methodNotAllowedHandler: defaultMethodNotAllowedHandler, @@ -94,70 +94,70 @@ func (k *Kid) Use(middleware MiddlewareFunc) { // // Specifying middlewares is optional. Middlewares will only be applied to this route. func (k *Kid) Get(path string, handler HandlerFunc, middlewares ...MiddlewareFunc) { - k.router.add(path, handler, []string{http.MethodGet}, middlewares) + k.router.insertNode(path, []string{http.MethodGet}, middlewares, handler) } // Post registers a new handler for the given path for POST method. // // Specifying middlewares is optional. Middlewares will only be applied to this route. func (k *Kid) Post(path string, handler HandlerFunc, middlewares ...MiddlewareFunc) { - k.router.add(path, handler, []string{http.MethodPost}, middlewares) + k.router.insertNode(path, []string{http.MethodPost}, middlewares, handler) } // Put registers a new handler for the given path for PUT method. // // Specifying middlewares is optional. Middlewares will only be applied to this route. func (k *Kid) Put(path string, handler HandlerFunc, middlewares ...MiddlewareFunc) { - k.router.add(path, handler, []string{http.MethodPut}, middlewares) + k.router.insertNode(path, []string{http.MethodPut}, middlewares, handler) } // Patch registers a new handler for the given path for PATCH method. // // Specifying middlewares is optional. Middlewares will only be applied to this route. func (k *Kid) Patch(path string, handler HandlerFunc, middlewares ...MiddlewareFunc) { - k.router.add(path, handler, []string{http.MethodPatch}, middlewares) + k.router.insertNode(path, []string{http.MethodPatch}, middlewares, handler) } // Delete registers a new handler for the given path for DELETE method. // // Specifying middlewares is optional. Middlewares will only be applied to this route. func (k *Kid) Delete(path string, handler HandlerFunc, middlewares ...MiddlewareFunc) { - k.router.add(path, handler, []string{http.MethodDelete}, middlewares) + k.router.insertNode(path, []string{http.MethodDelete}, middlewares, handler) } // Head registers a new handler for the given path for HEAD method. // // Specifying middlewares is optional. Middlewares will only be applied to this route. func (k *Kid) Head(path string, handler HandlerFunc, middlewares ...MiddlewareFunc) { - k.router.add(path, handler, []string{http.MethodHead}, middlewares) + k.router.insertNode(path, []string{http.MethodHead}, middlewares, handler) } // Options registers a new handler for the given path for OPTIONS method. // // Specifying middlewares is optional. Middlewares will only be applied to this route. func (k *Kid) Options(path string, handler HandlerFunc, middlewares ...MiddlewareFunc) { - k.router.add(path, handler, []string{http.MethodOptions}, middlewares) + k.router.insertNode(path, []string{http.MethodOptions}, middlewares, handler) } // Connect registers a new handler for the given path for CONNECT method. // // Specifying middlewares is optional. Middlewares will only be applied to this route. func (k *Kid) Connect(path string, handler HandlerFunc, middlewares ...MiddlewareFunc) { - k.router.add(path, handler, []string{http.MethodConnect}, middlewares) + k.router.insertNode(path, []string{http.MethodConnect}, middlewares, handler) } // Trace registers a new handler for the given path for TRACE method. // // Specifying middlewares is optional. Middlewares will only be applied to this route. func (k *Kid) Trace(path string, handler HandlerFunc, middlewares ...MiddlewareFunc) { - k.router.add(path, handler, []string{http.MethodTrace}, middlewares) + k.router.insertNode(path, []string{http.MethodTrace}, middlewares, handler) } // Any registers a new handler for the given path for all of the HTTP methods. // // Specifying middlewares is optional. Middlewares will only be applied to this route. func (k *Kid) Any(path string, handler HandlerFunc, middlewares ...MiddlewareFunc) { - k.router.add(path, handler, allMethods, middlewares) + k.router.insertNode(path, allMethods, middlewares, handler) } // Group creates a new router group. @@ -172,7 +172,7 @@ func (k *Kid) Group(prefix string, middlewares ...MiddlewareFunc) Group { // // Specifying middlewares is optional. Middlewares will only be applied to this route. func (k *Kid) Add(path string, handler HandlerFunc, methods []string, middlewares ...MiddlewareFunc) { - k.router.add(path, handler, methods, middlewares) + k.router.insertNode(path, methods, middlewares, handler) } // Static registers a new route for serving static files. @@ -188,10 +188,9 @@ func (k *Kid) Static(urlPath, staticRoot string, middlewares ...MiddlewareFunc) func (k *Kid) StaticFS(urlPath string, fs http.FileSystem, middlewares ...MiddlewareFunc) { fileServer := newFileServer(urlPath, fs) - methods := []string{http.MethodGet} path := appendSlash(urlPath) + "{*filePath}" - k.router.add(path, WrapHandler(fileServer), methods, middlewares) + k.router.insertNode(path, []string{http.MethodGet}, middlewares, WrapHandler(fileServer)) } // ServeHTTP implements the http.HandlerFunc interface. @@ -199,7 +198,7 @@ func (k *Kid) ServeHTTP(w http.ResponseWriter, r *http.Request) { c := k.pool.Get().(*Context) c.reset(r, w) - route, params, err := k.router.find(getPath(r.URL), r.Method) + route, params, err := k.router.search(getPath(r.URL), r.Method) c.setParams(params) diff --git a/kid_test.go b/kid_test.go index e7f277e..f2231a9 100644 --- a/kid_test.go +++ b/kid_test.go @@ -18,7 +18,7 @@ func TestNew(t *testing.T) { k := New() assert.NotNil(t, k) - assert.Equal(t, newRouter(), k.router) + assert.Equal(t, newTree(), k.router) assert.Equal(t, 0, len(k.middlewares)) assert.Equal(t, serializer.NewJSONSerializer(), k.jsonSerializer) assert.Equal(t, serializer.NewXMLSerializer(), k.xmlSerializer) @@ -57,11 +57,6 @@ func TestKid_Get(t *testing.T) { c.JSON(http.StatusOK, Map{"message": fmt.Sprintf("Hello %s", name)}) }) - assert.Equal(t, 2, len(k.router.routes)) - assert.Equal(t, 1, len(k.router.routes[0].methods)) - assert.Equal(t, 0, len(k.router.routes[0].middlewares)) - assert.Equal(t, http.MethodGet, k.router.routes[0].methods[0]) - req := httptest.NewRequest(http.MethodGet, "/test", nil) res := httptest.NewRecorder() @@ -91,11 +86,6 @@ func TestKid_Post(t *testing.T) { c.JSON(http.StatusCreated, Map{"message": "ok"}) }) - assert.Equal(t, 1, len(k.router.routes)) - assert.Equal(t, 1, len(k.router.routes[0].methods)) - assert.Equal(t, 0, len(k.router.routes[0].middlewares)) - assert.Equal(t, http.MethodPost, k.router.routes[0].methods[0]) - req := httptest.NewRequest(http.MethodPost, "/test", nil) res := httptest.NewRecorder() @@ -117,11 +107,6 @@ func TestKid_Put(t *testing.T) { c.JSON(http.StatusCreated, Map{"message": "put"}) }) - assert.Equal(t, 1, len(k.router.routes)) - assert.Equal(t, 1, len(k.router.routes[0].methods)) - assert.Equal(t, 0, len(k.router.routes[0].middlewares)) - assert.Equal(t, http.MethodPut, k.router.routes[0].methods[0]) - req := httptest.NewRequest(http.MethodPut, "/test", nil) res := httptest.NewRecorder() @@ -143,11 +128,6 @@ func TestKid_Delete(t *testing.T) { c.JSON(http.StatusCreated, Map{"message": "deleted"}) }) - assert.Equal(t, 1, len(k.router.routes)) - assert.Equal(t, 1, len(k.router.routes[0].methods)) - assert.Equal(t, 0, len(k.router.routes[0].middlewares)) - assert.Equal(t, http.MethodDelete, k.router.routes[0].methods[0]) - req := httptest.NewRequest(http.MethodDelete, "/test", nil) res := httptest.NewRecorder() @@ -169,11 +149,6 @@ func TestKid_Patch(t *testing.T) { c.JSON(http.StatusCreated, Map{"message": "patch"}) }) - assert.Equal(t, 1, len(k.router.routes)) - assert.Equal(t, 1, len(k.router.routes[0].methods)) - assert.Equal(t, 0, len(k.router.routes[0].middlewares)) - assert.Equal(t, http.MethodPatch, k.router.routes[0].methods[0]) - req := httptest.NewRequest(http.MethodPatch, "/test", nil) res := httptest.NewRecorder() @@ -195,11 +170,6 @@ func TestKid_Trace(t *testing.T) { c.JSON(http.StatusCreated, Map{"message": "trace"}) }) - assert.Equal(t, 1, len(k.router.routes)) - assert.Equal(t, 1, len(k.router.routes[0].methods)) - assert.Equal(t, 0, len(k.router.routes[0].middlewares)) - assert.Equal(t, http.MethodTrace, k.router.routes[0].methods[0]) - req := httptest.NewRequest(http.MethodTrace, "/test", nil) res := httptest.NewRecorder() @@ -221,11 +191,6 @@ func TestKid_Connect(t *testing.T) { c.JSON(http.StatusCreated, Map{"message": "connect"}) }) - assert.Equal(t, 1, len(k.router.routes)) - assert.Equal(t, 1, len(k.router.routes[0].methods)) - assert.Equal(t, 0, len(k.router.routes[0].middlewares)) - assert.Equal(t, http.MethodConnect, k.router.routes[0].methods[0]) - req := httptest.NewRequest(http.MethodConnect, "/test", nil) res := httptest.NewRecorder() @@ -247,11 +212,6 @@ func TestKid_Options(t *testing.T) { c.JSON(http.StatusCreated, Map{"message": "options"}) }) - assert.Equal(t, 1, len(k.router.routes)) - assert.Equal(t, 1, len(k.router.routes[0].methods)) - assert.Equal(t, 0, len(k.router.routes[0].middlewares)) - assert.Equal(t, http.MethodOptions, k.router.routes[0].methods[0]) - req := httptest.NewRequest(http.MethodOptions, "/test", nil) res := httptest.NewRecorder() @@ -273,11 +233,6 @@ func TestKid_Head(t *testing.T) { c.JSON(http.StatusCreated, Map{"message": "head"}) }) - assert.Equal(t, 1, len(k.router.routes)) - assert.Equal(t, 1, len(k.router.routes[0].methods)) - assert.Equal(t, 0, len(k.router.routes[0].middlewares)) - assert.Equal(t, http.MethodHead, k.router.routes[0].methods[0]) - req := httptest.NewRequest(http.MethodHead, "/test", nil) res := httptest.NewRecorder() @@ -299,11 +254,6 @@ func TestKid_Add(t *testing.T) { c.JSON(http.StatusCreated, Map{"message": c.Request().Method}) }, []string{http.MethodGet, http.MethodPost}) - assert.Equal(t, 1, len(k.router.routes)) - assert.Equal(t, 2, len(k.router.routes[0].methods)) - assert.Equal(t, 0, len(k.router.routes[0].middlewares)) - assert.Equal(t, []string{http.MethodGet, http.MethodPost}, k.router.routes[0].methods) - testCases := []struct { req *http.Request res *httptest.ResponseRecorder @@ -335,18 +285,6 @@ func TestKid_Any(t *testing.T) { c.JSON(http.StatusCreated, Map{"message": c.Request().Method}) }) - assert.Equal(t, 1, len(k.router.routes)) - assert.Equal(t, 9, len(k.router.routes[0].methods)) - assert.Equal(t, 0, len(k.router.routes[0].middlewares)) - assert.Equal(t, - []string{ - http.MethodGet, http.MethodPost, http.MethodPut, - http.MethodPatch, http.MethodDelete, http.MethodHead, - http.MethodOptions, http.MethodConnect, http.MethodTrace, - }, - k.router.routes[0].methods, - ) - testCases := []struct { req *http.Request res *httptest.ResponseRecorder diff --git a/mux.go b/mux.go deleted file mode 100644 index da536b3..0000000 --- a/mux.go +++ /dev/null @@ -1,242 +0,0 @@ -package kid - -import ( - "bytes" - "errors" - "strings" -) - -// Errors. -var ( - errNotFound = errors.New("match not found") - errMethodNotAllowed = errors.New("method is not allowed") -) - -// Path parameters prefix and suffix. -const ( - paramPrefix = "{" - paramSuffix = "}" - plusParamPrefix = paramPrefix + "+" - starParamPrefix = paramPrefix + "*" -) - -type ( - // Router is the struct which holds all of the routes. - Router struct { - routes []Route - } - - // Route is a route with its contents. - Route struct { - segments []Segment - methods []string - handler HandlerFunc - middlewares []MiddlewareFunc - } - - // Segment is the type of each path segment. - Segment struct { - isParam bool - isPlus bool - isStar bool - tpl string - } - - // Params is the type of path parameters. - Params map[string]string -) - -// newRouter returns a new router. -func newRouter() Router { - return Router{routes: make([]Route, 0)} -} - -// add adds a route to the router. -func (router *Router) add(path string, handler HandlerFunc, methods []string, middlewares []MiddlewareFunc) { - if len(methods) == 0 { - panic("providing at least one method is required") - } - - panicIfNil(handler, "handler cannot be nil") - - path = cleanPath(path, false) - - segments := strings.Split(path, "/")[1:] - - routeSegments := make([]Segment, 0, len(segments)) - - for i, segment := range segments { - if (strings.HasPrefix(segment, plusParamPrefix) || strings.HasPrefix(segment, starParamPrefix)) && strings.HasSuffix(segment, paramSuffix) { - isPlus := router.isPlus(segment) - isStar := !isPlus - - if i == len(segments)-1 { - routeSegments = append( - routeSegments, - Segment{isParam: true, isPlus: isPlus, isStar: isStar, tpl: segment[2 : len(segment)-1]}, - ) - } else if i == len(segments)-2 { - if segments[i+1] != "" { - panic("plus/star path parameters can only be the last part of a path") - } - routeSegments = append( - routeSegments, - Segment{isParam: true, isPlus: isPlus, isStar: isStar, tpl: segment[2 : len(segment)-1]}, - ) - break - } else { - panic("plus/star path parameters can only be the last part of a path") - } - } else if strings.HasPrefix(segment, "{") && strings.HasSuffix(segment, "}") { - routeSegments = append(routeSegments, Segment{isParam: true, tpl: segment[1 : len(segment)-1]}) - } else { - routeSegments = append(routeSegments, Segment{isParam: false, tpl: segment}) - } - } - - router.routes = append(router.routes, Route{segments: routeSegments, methods: methods, handler: handler, middlewares: middlewares}) -} - -// match determines if the given path and method matches the route. -func (route *Route) match(path, method string) (Params, error) { - params := make(Params) - totalSegments := len(route.segments) - var end bool - - for segmentIndex, segment := range route.segments { - i := strings.IndexByte(path, '/') - j := i + 1 - - if i == -1 { - i = len(path) - j = i - end = true - - // No slashes are left but there are still more segments. - if segmentIndex != totalSegments-1 { - // It means /api/v1 will be matched to /api/v1/{*param} - lastSegment := route.segments[totalSegments-1] - if segmentIndex == totalSegments-2 && lastSegment.isStar { - end = true - params[lastSegment.tpl] = "" - } else { - return nil, errNotFound - } - } - } - - if segment.isParam { - if segment.isPlus || segment.isStar { - if len(path) == 0 && segment.isPlus { - return nil, errNotFound - } - - end = true - params[segment.tpl] = path - - // Break because it's always the last part of the path. - break - } - - params[segment.tpl] = path[:i] - - // Empty parameter - if len(path[:i]) == 0 { - return nil, errNotFound - } - } else { - if segment.tpl != path[:i] { - return nil, errNotFound - } - } - - path = path[j:] - } - - // Segments are ended but there are still more slashes. - if !end { - return nil, errNotFound - } - - if !methodExists(method, route.methods) { - return nil, errMethodNotAllowed - } - - return params, nil -} - -// find finds a route which matches the given path and method. -func (router *Router) find(path string, method string) (Route, Params, error) { - path = cleanPath(path, true)[1:] - - var returnedErr error - - // We have no routes, so anything won't be found. - if len(router.routes) == 0 { - return Route{}, nil, errNotFound - } - - for _, route := range router.routes { - params, err := route.match(path, method) - if err == nil { - return route, params, nil - } - - if err == errMethodNotAllowed { - returnedErr = err - } else if returnedErr == nil { - returnedErr = err - } - } - - return Route{}, nil, returnedErr - -} - -// isPlus returns true if path parameter is plus path parameter. -func (router *Router) isPlus(segment string) bool { - var isPlus bool - if strings.HasPrefix(segment, plusParamPrefix) { - isPlus = true - } - return isPlus -} - -// cleanPath normalizes the path. -// -// If soft is false it also removes duplicate slashes. -func cleanPath(s string, soft bool) string { - if s == "" { - return "/" - } - - if s[0] != '/' { - s = "/" + s - } - - if soft { - return s - } - - // Removing repeated slashes. - var buff bytes.Buffer - for i := 0; i < len(s); i++ { - if i != 0 && s[i] == '/' && s[i-1] == '/' { - continue - } - buff.WriteByte(s[i]) - } - - return buff.String() -} - -// methodExists checks whether a method exists in a slice of methods. -func methodExists(method string, methods []string) bool { - for _, v := range methods { - if v == method { - return true - } - } - - return false -} diff --git a/mux_test.go b/mux_test.go deleted file mode 100644 index 0043a19..0000000 --- a/mux_test.go +++ /dev/null @@ -1,358 +0,0 @@ -package kid - -import ( - "net/http" - "reflect" - "testing" - - "github.com/stretchr/testify/assert" -) - -var ( - testHandlerFunc HandlerFunc = func(c *Context) {} - - testMiddlewareFunc MiddlewareFunc = func(next HandlerFunc) HandlerFunc { - return func(c *Context) { - next(c) - } - } -) - -// funcsAreEqual checks if two functions have the same pointer value. -func funcsAreEqual(x, y any) bool { - return reflect.ValueOf(x).Pointer() == reflect.ValueOf(y).Pointer() -} - -func TestNewRouter(t *testing.T) { - router := newRouter() - - assert.NotNil(t, router.routes) - assert.Empty(t, router.routes) -} - -func TestMethodExists(t *testing.T) { - assert.False(t, methodExists("GET", []string{"POST", "DELETE"})) - assert.True(t, methodExists("GET", []string{"POST", "DELETE", "GET"})) -} - -func TestCleanPath(t *testing.T) { - slash := cleanPath("", true) - - assert.Equal(t, "/", slash) - - prefixSlash := cleanPath("test", true) - - assert.Equal(t, "/test", prefixSlash) - - cleanedPath := cleanPath("//api///v1////books/offer", false) - - assert.Equal(t, "/api/v1/books/offer", cleanedPath) -} - -func TestRouter_add(t *testing.T) { - router := newRouter() - - assert.PanicsWithValue(t, "providing at least one method is required", func() { - router.add("/", testHandlerFunc, nil, nil) - }) - - assert.PanicsWithValue(t, "handler cannot be nil", func() { - router.add("/", nil, []string{http.MethodGet}, nil) - }) - - assert.PanicsWithValue(t, "plus/star path parameters can only be the last part of a path", func() { - router.add("/path/{+extraPath}/asd", testHandlerFunc, []string{http.MethodGet}, nil) - }) - - assert.PanicsWithValue(t, "plus/star path parameters can only be the last part of a path", func() { - router.add("/path/{+extraPath}/test/test2", testHandlerFunc, []string{http.MethodGet}, nil) - }) - - router.add("/test/list/", testHandlerFunc, []string{http.MethodGet}, nil) - - router.add("/test/{var}/get", testHandlerFunc, []string{http.MethodGet, http.MethodPost}, []MiddlewareFunc{testMiddlewareFunc}) - - router.add("/test/{+extraPath}", testHandlerFunc, []string{http.MethodPost}, nil) - - router.add("/path/{+extraPath}/", testHandlerFunc, []string{http.MethodDelete}, nil) - - assert.Equal(t, 4, len(router.routes)) - - testCases := []struct { - route Route - name string - }{ - { - name: "/test/list/", - route: Route{ - methods: []string{http.MethodGet}, - handler: testHandlerFunc, - segments: []Segment{{isParam: false, tpl: "test"}, {isParam: false, tpl: "list"}, {isParam: false, tpl: ""}}, - middlewares: nil, - }, - }, - { - name: "/test/{var}/get", - route: Route{ - methods: []string{http.MethodGet, http.MethodPost}, - handler: testHandlerFunc, - segments: []Segment{{isParam: false, tpl: "test"}, {isParam: true, tpl: "var"}, {isParam: false, tpl: "get"}}, - middlewares: []MiddlewareFunc{testMiddlewareFunc}, - }, - }, - { - name: "/test/{+extraPath}", - route: Route{ - methods: []string{http.MethodPost}, - handler: testHandlerFunc, - segments: []Segment{{isParam: false, isPlus: false, tpl: "test"}, {isParam: true, isPlus: true, tpl: "extraPath"}}, - middlewares: nil, - }, - }, - { - name: "/path/{+extraPath}", - route: Route{ - methods: []string{http.MethodDelete}, - handler: testHandlerFunc, - segments: []Segment{{isParam: false, isPlus: false, tpl: "path"}, {isParam: true, isPlus: true, tpl: "extraPath"}}, - middlewares: nil, - }, - }, - } - - for i := 0; i < len(testCases); i++ { - testCase := testCases[i] - t.Run(testCase.name, func(t *testing.T) { - route := router.routes[i] - - assert.Equal(t, testCase.route.methods, route.methods) - assert.Equal(t, testCase.route.segments, route.segments) - assert.Equal(t, len(testCase.route.middlewares), len(route.middlewares)) - assert.True(t, funcsAreEqual(testCase.route.handler, route.handler)) - - for i := 0; i < len(testCase.route.middlewares); i++ { - expectedMiddlewareFunc := testCase.route.middlewares[i] - middlewareFunc := route.middlewares[i] - - assert.True(t, funcsAreEqual(expectedMiddlewareFunc, middlewareFunc)) - } - }) - } -} - -func TestRouter_match(t *testing.T) { - router := newRouter() - - router.add("/", testHandlerFunc, []string{http.MethodGet}, nil) - router.add("/test/{var}/get", testHandlerFunc, []string{http.MethodGet, http.MethodPost}, nil) - router.add("/test/{var}/get/{+plusPath}", testHandlerFunc, []string{http.MethodPut}, nil) - router.add("/test/{var}/path/{*starPath}", testHandlerFunc, []string{http.MethodGet}, nil) - - firstRoute := router.routes[0] - secondRoute := router.routes[1] - plusRoute := router.routes[2] - starRoute := router.routes[3] - - // Don't need to add starting slash in route's match method as they are skipped in router's find method. - // For matching we should match relative paths, not abosulute paths. - - // Testing first route. - params, err := firstRoute.match("", http.MethodGet) - assert.NoError(t, err) - assert.Equal(t, 0, len(params)) - - params, err = firstRoute.match("", http.MethodGet) - assert.NoError(t, err) - assert.Equal(t, 0, len(params)) - - params, err = firstRoute.match("a", http.MethodGet) - assert.ErrorIs(t, err, errNotFound) - assert.Nil(t, params) - - params, err = firstRoute.match("", http.MethodPost) - assert.ErrorIs(t, err, errMethodNotAllowed) - assert.Nil(t, params) - - params, err = firstRoute.match("/", http.MethodGet) - assert.ErrorIs(t, err, errNotFound) - assert.Nil(t, params) - - // Testing second route. - params, err = secondRoute.match("test/hello/get", http.MethodGet) - assert.NoError(t, err) - assert.Equal(t, Params{"var": "hello"}, params) - - params, err = secondRoute.match("test/123/get", http.MethodPost) - assert.NoError(t, err) - assert.Equal(t, Params{"var": "123"}, params) - - params, err = secondRoute.match("test/hello/get/", http.MethodGet) - assert.ErrorIs(t, err, errNotFound) - assert.Nil(t, params) - - params, err = secondRoute.match("test/hello/get", http.MethodPut) - assert.ErrorIs(t, err, errMethodNotAllowed) - assert.Nil(t, params) - - params, err = secondRoute.match("test/hello/get2", http.MethodGet) - assert.ErrorIs(t, err, errNotFound) - assert.Nil(t, params) - - params, err = secondRoute.match("test/hello/get/extra", http.MethodGet) - assert.ErrorIs(t, err, errNotFound) - assert.Nil(t, params) - - params, err = secondRoute.match("test/hello/", http.MethodGet) - assert.ErrorIs(t, err, errNotFound) - assert.Nil(t, params) - - params, err = secondRoute.match("test/hello", http.MethodGet) - assert.ErrorIs(t, err, errNotFound) - assert.Nil(t, params) - - // Path varibales are required and cannot be empty. - params, err = secondRoute.match("test//get", http.MethodGet) - assert.ErrorIs(t, err, errNotFound) - assert.Nil(t, params) - - // Testing plus route. - params, err = plusRoute.match("test/123/get/extra/path", http.MethodPut) - assert.NoError(t, err) - assert.Equal(t, Params{"var": "123", "plusPath": "extra/path"}, params) - - params, err = plusRoute.match("test/123/get/extra", http.MethodPut) - assert.NoError(t, err) - assert.Equal(t, Params{"var": "123", "plusPath": "extra"}, params) - - params, err = plusRoute.match("test/123/get/extra/path", http.MethodGet) - assert.ErrorIs(t, err, errMethodNotAllowed) - assert.Nil(t, params) - - params, err = plusRoute.match("test//get/extra/path", http.MethodPut) - assert.ErrorIs(t, err, errNotFound) - assert.Nil(t, params) - - // At least one extra path is required - params, err = plusRoute.match("test/123/get/", http.MethodPut) - assert.ErrorIs(t, err, errNotFound) - assert.Nil(t, params) - - // Testing star route. - params, err = starRoute.match("test/123/path/star/path", http.MethodGet) - assert.NoError(t, err) - assert.Equal(t, Params{"var": "123", "starPath": "star/path"}, params) - - params, err = starRoute.match("test/123/path/star", http.MethodGet) - assert.NoError(t, err) - assert.Equal(t, Params{"var": "123", "starPath": "star"}, params) - - params, err = starRoute.match("test/123/path/", http.MethodGet) - assert.NoError(t, err) - assert.Equal(t, Params{"var": "123", "starPath": ""}, params) - - params, err = starRoute.match("test/123/path", http.MethodGet) - assert.NoError(t, err) - assert.Equal(t, Params{"var": "123", "starPath": ""}, params) - - params, err = starRoute.match("test/123/path/star/path", http.MethodPost) - assert.ErrorIs(t, err, errMethodNotAllowed) - assert.Nil(t, params) - - params, err = starRoute.match("test//path/star/path", http.MethodGet) - assert.ErrorIs(t, err, errNotFound) - assert.Nil(t, params) -} - -func TestRouter_find(t *testing.T) { - router := newRouter() - - router.add("/", testHandlerFunc, []string{http.MethodGet}, nil) - router.add("/test/hi", testHandlerFunc, []string{http.MethodGet}, nil) - router.add("/test/{var}", testHandlerFunc, []string{http.MethodGet, http.MethodPost}, []MiddlewareFunc{testMiddlewareFunc}) - - route, params, err := router.find("/", http.MethodGet) - assert.NoError(t, err) - assert.Equal(t, 0, len(params)) - assert.Equal(t, []string{http.MethodGet}, route.methods) - assert.Equal(t, []Segment{{tpl: "", isParam: false}}, route.segments) - assert.Nil(t, route.middlewares) - assert.True(t, funcsAreEqual(testHandlerFunc, route.handler)) - - _, params, err = router.find("", http.MethodGet) - assert.NoError(t, err) - assert.Equal(t, 0, len(params)) - - route, params, err = router.find("/test/123", http.MethodGet) - assert.NoError(t, err) - assert.Equal(t, Params{"var": "123"}, params) - assert.Equal(t, []string{http.MethodGet, http.MethodPost}, route.methods) - assert.Equal(t, []Segment{{tpl: "test", isParam: false}, {tpl: "var", isParam: true}}, route.segments) - assert.Equal(t, 1, len(route.middlewares)) - assert.True(t, funcsAreEqual(testHandlerFunc, route.handler)) - assert.True(t, funcsAreEqual(testMiddlewareFunc, route.middlewares[0])) - - _, params, err = router.find("test/123", http.MethodGet) - assert.NoError(t, err) - assert.Equal(t, Params{"var": "123"}, params) - - route, params, err = router.find("/test/123", http.MethodPost) - assert.NoError(t, err) - assert.Equal(t, Params{"var": "123"}, params) - assert.Equal(t, []string{http.MethodGet, http.MethodPost}, route.methods) - assert.Equal(t, []Segment{{tpl: "test", isParam: false}, {tpl: "var", isParam: true}}, route.segments) - assert.Equal(t, 1, len(route.middlewares)) - assert.True(t, funcsAreEqual(testHandlerFunc, route.handler)) - assert.True(t, funcsAreEqual(testMiddlewareFunc, route.middlewares[0])) - - _, params, err = router.find("/test/123/", http.MethodGet) - assert.ErrorIs(t, err, errNotFound) - assert.Nil(t, params) - - _, params, err = router.find("/test/123/", http.MethodPost) - assert.ErrorIs(t, err, errNotFound) - assert.Nil(t, params) - - _, params, err = router.find("/test/123", http.MethodPut) - assert.ErrorIs(t, err, errMethodNotAllowed) - assert.Nil(t, params) - - _, params, err = router.find("/test/123/extra", http.MethodGet) - assert.ErrorIs(t, err, errNotFound) - assert.Nil(t, params) - - _, params, err = router.find("/test", http.MethodGet) - assert.ErrorIs(t, err, errNotFound) - assert.Nil(t, params) - - _, params, err = router.find("/test/", http.MethodGet) - assert.ErrorIs(t, err, errNotFound) - assert.Nil(t, params) - - // The first added methods have higher priority. - route, params, err = router.find("/test/hi", http.MethodGet) - assert.NoError(t, err) - assert.Equal(t, 0, len(params)) - assert.Equal(t, []string{http.MethodGet}, route.methods) - assert.Equal(t, []Segment{{tpl: "test", isParam: false}, {tpl: "hi", isParam: false}}, route.segments) - assert.Nil(t, route.middlewares) - assert.True(t, funcsAreEqual(testHandlerFunc, route.handler)) - - route, params, err = router.find("/test/hi", http.MethodPost) - assert.NoError(t, err) - assert.Equal(t, Params{"var": "hi"}, params) - assert.Equal(t, []string{http.MethodGet, http.MethodPost}, route.methods) - assert.Equal(t, []Segment{{tpl: "test", isParam: false}, {tpl: "var", isParam: true}}, route.segments) - assert.Equal(t, 1, len(route.middlewares)) - assert.True(t, funcsAreEqual(testHandlerFunc, route.handler)) - assert.True(t, funcsAreEqual(testMiddlewareFunc, route.middlewares[0])) -} - -func TestRouter_isPlus(t *testing.T) { - router := newRouter() - - isPlus := router.isPlus("{+param}") - assert.True(t, isPlus) - - isPlus = router.isPlus("{*param}") - assert.False(t, isPlus) -} diff --git a/tree.go b/tree.go new file mode 100644 index 0000000..f7a6d41 --- /dev/null +++ b/tree.go @@ -0,0 +1,302 @@ +package kid + +import ( + "bytes" + "errors" + "fmt" + "strings" +) + +// Errors. +var ( + errNotFound = errors.New("match not found") + errMethodNotAllowed = errors.New("method is not allowed") +) + +// Path parameters prefix and suffix. +const ( + paramPrefix = "{" + paramSuffix = "}" + plusParamPrefix = paramPrefix + "+" + starParamPrefix = paramPrefix + "*" +) + +type ( + // handlerMiddleware zips a handler and its middlewares to each other. + handlerMiddleware struct { + handler HandlerFunc + middlewares []MiddlewareFunc + } + + // Tree is a tree used for routing. + Tree struct { + // size is the number of nodes in the tree. + size uint32 + + // root of the tree. + root *Node + } + + // Node is a tree node. + Node struct { + // id is the id of each node. It separates every node from each other. + id uint32 + + // label of the node. + label string + + children []*Node + + isParam bool + isStar bool + + // handlerMap maps HTTP methods to their handlers. + handlerMap map[string]handlerMiddleware + } + + // Params is the type of path parameters. + Params map[string]string +) + +// newNode returns a new node. +func newNode() Node { + return Node{ + children: make([]*Node, 0), + handlerMap: make(map[string]handlerMiddleware), + } +} + +// newTree returns a new Tree. +func newTree() Tree { + node := newNode() + node.id = 1 + + return Tree{ + size: 1, + root: &node, + } +} + +// insert inserts a new node into the tree. +func (t *Tree) insertNode(path string, methods []string, middlewares []MiddlewareFunc, handler HandlerFunc) { + if len(methods) == 0 { + panic("providing at least one method is required") + } + + panicIfNil(handler, "handler cannot be nil") + + path = cleanPath(path, false) + + segments := strings.Split(path, "/")[1:] + + currNode := t.root + + for i, segment := range segments { + node := newNode() + + node.isParam = isParam(segment) + node.isStar = isStar(segment) + node.setLabel(segment) + node.id = t.size + 1 + t.size++ + + if i != len(segments)-1 { + if node.isStar { + panic("star path parameters can only be the last part of a path") + } + + if child := currNode.getChild(node.label, node.isParam, node.isStar); child == nil { + currNode.addChild(&node) + currNode = &node + } else { + currNode = child + } + } else { // Only for the last iteration of the for loop. + if child := currNode.getChild(node.label, node.isParam, node.isStar); child == nil { + node.addHanlder(methods, handlerMiddleware{handler: handler, middlewares: middlewares}) + currNode.addChild(&node) + } else { + child.addHanlder(methods, handlerMiddleware{handler: handler, middlewares: middlewares}) + } + } + } +} + +// doesMatch deterines if the path matches the node's label. +func (n Node) doesMatch(path []string, pos int) bool { + if n.isStar { + return true + } + + if pos >= len(path) { + return false + } + + // Param matching. + if n.isParam { + return path[pos] != "" + } + + // Exact matching. + return path[pos] == n.label +} + +// searchFinished returns true if the search has to be finished. +func (n Node) searchFinished(path []string, pos int) bool { + if pos+1 == len(path) && len(n.handlerMap) > 0 { + return true + } + return n.isStar +} + +// getPathParam returns the path parameter. +func (n *Node) getPathParam(path []string, pos int) string { + if n.isStar { + return strings.Join(path[pos:], "/") + } + + return path[pos] +} + +// getChild returns the specified child of the node. +func (n Node) getChild(label string, isParam, isStar bool) *Node { + for i := 0; i < len(n.children); i++ { + if n.children[i].label == label && n.children[i].isParam == isParam && n.children[i].isStar == isStar { + return n.children[i] + } + } + + return nil +} + +// addChild adds the given node to the node's children. +func (n *Node) addChild(node *Node) { + n.children = append(n.children, node) +} + +// addHanlders add handlers to their methods. +func (n *Node) addHanlder(methods []string, hm handlerMiddleware) { + for _, v := range methods { + if _, ok := n.handlerMap[v]; ok { + panic(fmt.Sprintf("handler is already registered for method %s and node %+v.", v, n)) + } + + n.handlerMap[v] = hm + } +} + +func (n *Node) setLabel(label string) { + n.label = label + if n.isParam { + if n.isStar { + n.label = label[2 : len(label)-1] + } else { + n.label = label[1 : len(label)-1] + } + } +} + +// isParam determines if a label is a parameter. +func isParam(label string) bool { + if strings.HasPrefix(label, paramPrefix) && strings.HasSuffix(label, paramSuffix) { + return true + } + return false +} + +// isStar checks if a parameter is a star parameter. +func isStar(label string) bool { + if isParam(label) && label[1] == '*' { + return true + } + return false +} + +// searchDFS searches the tree with the DFS search algorithm. +func searchDFS( + stack []*Node, + visitedMap map[uint32]bool, + params Params, + path []string, + pos int, +) (map[string]handlerMiddleware, Params, bool) { + if len(stack) == 0 { + return nil, params, false + } + + node := stack[len(stack)-1] // accessing last element + + if !visitedMap[node.id] { + visitedMap[node.id] = true + + if node.isParam { + params[node.label] = node.getPathParam(path, pos) + } + + if node.searchFinished(path, pos) { + return node.handlerMap, params, true + } + } + + for _, child := range node.children { + if !visitedMap[child.id] && child.doesMatch(path, pos+1) { + stack = append(stack, child) + return searchDFS(stack, visitedMap, params, path, pos+1) + } + } + + if node.isParam { + delete(params, node.label) + } + + stack = stack[:len(stack)-1] + return searchDFS(stack, visitedMap, params, path, pos-1) +} + +// search searches the Tree and tries to match the path to a handler if possible. +func (t Tree) search(path, method string) (handlerMiddleware, Params, error) { + segments := strings.Split(path, "/") + stack := []*Node{t.root} + visitedMap := map[uint32]bool{} + params := make(Params) + + hmMap, params, found := searchDFS(stack, visitedMap, params, segments, 0) + + if !found { + return handlerMiddleware{}, params, errNotFound + } + + if hm, ok := hmMap[method]; ok { + return hm, params, nil + } + + return handlerMiddleware{}, params, errMethodNotAllowed +} + +// cleanPath normalizes the path. +// +// If soft is false it also removes duplicate slashes. +func cleanPath(s string, soft bool) string { + if s == "" { + return "/" + } + + if s[0] != '/' { + s = "/" + s + } + + if soft { + return s + } + + // Removing repeated slashes. + var buff bytes.Buffer + for i := 0; i < len(s); i++ { + if i != 0 && s[i] == '/' && s[i-1] == '/' { + continue + } + buff.WriteByte(s[i]) + } + + return buff.String() +} diff --git a/tree_test.go b/tree_test.go new file mode 100644 index 0000000..d4bbec0 --- /dev/null +++ b/tree_test.go @@ -0,0 +1,318 @@ +package kid + +import ( + "fmt" + "net/http" + "reflect" + "testing" + + "github.com/stretchr/testify/assert" +) + +var ( + testHandlerFunc HandlerFunc = func(c *Context) {} + + testMiddlewareFunc MiddlewareFunc = func(next HandlerFunc) HandlerFunc { + return func(c *Context) { + next(c) + } + } +) + +// funcsAreEqual checks if two functions have the same pointer value. +func funcsAreEqual(x, y any) bool { + return reflect.ValueOf(x).Pointer() == reflect.ValueOf(y).Pointer() +} + +func TestNewNode(t *testing.T) { + node := newNode() + + assert.Empty(t, node.handlerMap) + assert.Empty(t, node.children) +} + +func TestNewTree(t *testing.T) { + tree := newTree() + + assert.NotNil(t, tree.root) + assert.EqualValues(t, 1, tree.size) +} + +func TestNode_getChild(t *testing.T) { + node := newNode() + + childNode := newNode() + childNode.label = "test" + + node.addChild(&childNode) + + assert.Equal(t, &childNode, node.getChild("test", childNode.isParam, childNode.isStar)) + assert.Nil(t, node.getChild("test", !childNode.isParam, childNode.isStar)) + assert.Nil(t, node.getChild("test", childNode.isParam, !childNode.isStar)) + assert.Nil(t, node.getChild("test2", childNode.isParam, childNode.isStar)) +} + +func TestNode_addChild(t *testing.T) { + node := newNode() + + childNode := newNode() + childNode.label = "test" + + node.addChild(&childNode) + assert.Len(t, node.children, 1) +} + +func TestNode_addHanlder(t *testing.T) { + node := newNode() + + node.addHanlder([]string{http.MethodGet, http.MethodPost}, handlerMiddleware{}) + + assert.Len(t, node.handlerMap, 2) + + assert.PanicsWithValue( + t, + "handler is already registered for method GET and node &{id:0 label: children:[] isParam:false isStar:false handlerMap:map[GET:{handler: middlewares:[]} POST:{handler: middlewares:[]}]}.", + func() { + node.addHanlder([]string{http.MethodGet, http.MethodPost}, handlerMiddleware{}) + }, + ) +} + +func TestIsParam(t *testing.T) { + assert.True(t, isParam("{param}")) + + assert.False(t, isParam("param")) + + assert.False(t, isParam("param}")) + + assert.False(t, isParam("{param")) +} + +func TestIsStar(t *testing.T) { + assert.True(t, isStar("{*param}")) + + assert.False(t, isStar("{param}")) +} + +func TestTree_insertNode(t *testing.T) { + tree := newTree() + + tree.insertNode("/test/path", []string{http.MethodGet}, nil, testHandlerFunc) + + assert.False(t, tree.root.isParam) + assert.False(t, tree.root.isStar) + assert.Equal(t, "", tree.root.label) + assert.EqualValues(t, 1, tree.root.id) + + child := tree.root.getChild("test", false, false) + assert.False(t, child.isParam) + assert.False(t, child.isStar) + assert.Equal(t, "test", child.label) + assert.EqualValues(t, 2, child.id) + + _, ok := child.handlerMap[http.MethodGet] + assert.False(t, ok) + + child2 := child.getChild("path", false, false) + assert.False(t, child2.isParam) + assert.False(t, child2.isStar) + assert.Equal(t, "path", child2.label) + assert.EqualValues(t, 3, child2.id) + + hm, ok := child2.handlerMap[http.MethodGet] + assert.True(t, ok) + assert.True(t, funcsAreEqual(hm.handler, testHandlerFunc)) + assert.Nil(t, hm.middlewares) + + tree.insertNode("/test", []string{http.MethodPost}, []MiddlewareFunc{testMiddlewareFunc}, testHandlerFunc) + + assert.False(t, tree.root.isParam) + assert.False(t, tree.root.isStar) + assert.Equal(t, "", tree.root.label) + + child = tree.root.getChild("test", false, false) + assert.False(t, child.isParam) + assert.False(t, child.isStar) + assert.Equal(t, "test", child.label) + assert.EqualValues(t, 2, child.id) + + hm, ok = child.handlerMap[http.MethodPost] + assert.True(t, ok) + assert.True(t, funcsAreEqual(hm.handler, testHandlerFunc)) + assert.Len(t, hm.middlewares, 1) +} + +func TestNode_insert_Panics(t *testing.T) { + tree := newTree() + + assert.PanicsWithValue(t, "providing at least one method is required", func() { + tree.insertNode("/test", []string{}, nil, nil) + }) + + assert.PanicsWithValue(t, "handler cannot be nil", func() { + tree.insertNode("/test", []string{http.MethodGet}, nil, nil) + }) + + assert.PanicsWithValue(t, "star path parameters can only be the last part of a path", func() { + tree.insertNode("/{*starParam}/test", []string{http.MethodGet}, nil, testHandlerFunc) + }) +} + +func TestNode_setLabel(t *testing.T) { + n := newNode() + + n.isParam = false + n.setLabel("static") + assert.Equal(t, "static", n.label) + + n.isParam = true + n.setLabel("{param}") + assert.Equal(t, "param", n.label) + + n.isStar = true + n.setLabel("{*starParam}") + assert.Equal(t, "starParam", n.label) +} + +func TestCleanPath(t *testing.T) { + slash := cleanPath("", true) + + assert.Equal(t, "/", slash) + + prefixSlash := cleanPath("test", true) + + assert.Equal(t, "/test", prefixSlash) + + cleanedPath := cleanPath("//api///v1////books/offer", false) + + assert.Equal(t, "/api/v1/books/offer", cleanedPath) +} + +func TestDFS(t *testing.T) { + tree := newTree() + + tree.insertNode("/{path}/path1", []string{http.MethodGet}, nil, testHandlerFunc) + tree.insertNode("/{path}/path2", []string{http.MethodPost}, nil, testHandlerFunc) + tree.insertNode("/{path}/path2/", []string{http.MethodDelete}, nil, testHandlerFunc) + tree.insertNode("/path/test", []string{http.MethodPut}, nil, testHandlerFunc) + tree.insertNode("/path1/{*starParam}", []string{http.MethodPatch}, nil, testHandlerFunc) + + testCases := []struct { + method string + path []string + expectedParams Params + found bool + }{ + {method: http.MethodGet, path: []string{"", "param1", "path1"}, expectedParams: Params{"path": "param1"}, found: true}, + {method: http.MethodPost, path: []string{"", "param2", "path2"}, expectedParams: Params{"path": "param2"}, found: true}, + {method: http.MethodDelete, path: []string{"", "param", "path2", ""}, expectedParams: Params{"path": "param"}, found: true}, + {method: http.MethodPut, path: []string{"", "path", "test"}, expectedParams: Params{}, found: true}, + {method: http.MethodGet, path: []string{"", "path"}, expectedParams: Params{}, found: false}, + {method: http.MethodPatch, path: []string{"", "path1", "param1"}, expectedParams: Params{"starParam": "param1"}, found: true}, + {method: http.MethodPatch, path: []string{"", "path1", "param1", "param2"}, expectedParams: Params{"starParam": "param1/param2"}, found: true}, + {method: http.MethodPatch, path: []string{"", "path1"}, expectedParams: Params{"starParam": ""}, found: true}, + } + + for i, testCase := range testCases { + t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { + stack := []*Node{tree.root} + vm := map[uint32]bool{} + params := make(Params) + + hmMap, params, found := searchDFS(stack, vm, params, testCase.path, 0) + hm, ok := hmMap[testCase.method] + + assert.Equal(t, testCase.found, found) + assert.Equal(t, testCase.found, ok) + assert.Equal(t, testCase.expectedParams, params) + + if testCase.found { + assert.NotEmpty(t, hm) + } else { + assert.Empty(t, hm) + } + }) + } +} + +func TestNode_getPathParam(t *testing.T) { + node := newNode() + path := []string{"", "param", "path"} + + assert.Equal(t, "param", node.getPathParam(path, 1)) + + node.isStar = true + + assert.Equal(t, "param/path", node.getPathParam(path, 1)) +} + +func TestNode_searchFinished(t *testing.T) { + node := newNode() + path := []string{"", "param", "path"} + + assert.False(t, node.searchFinished(path, 2)) + assert.False(t, node.searchFinished(path, 1)) + + node.handlerMap[http.MethodGet] = handlerMiddleware{} + + assert.True(t, node.searchFinished(path, 2)) + assert.False(t, node.searchFinished(path, 1)) + + node.isStar = true + + assert.True(t, node.searchFinished(path, 2)) + assert.True(t, node.searchFinished(path, 1)) +} + +func TestNode_doesMatch(t *testing.T) { + node := newNode() + node.isStar = true + node.label = "lbl" + + assert.True(t, node.doesMatch([]string{"0", "1", "2"}, 0)) + + node.isStar = false + + assert.False(t, node.doesMatch([]string{"0", "1", "2"}, 3)) + + node.isParam = true + + assert.True(t, node.doesMatch([]string{"0", "1", "2"}, 2)) + assert.False(t, node.doesMatch([]string{"0", "1", ""}, 2)) + + node.isParam = false + + assert.True(t, node.doesMatch([]string{"0", "1", "lbl"}, 2)) + assert.False(t, node.doesMatch([]string{"0", "1", "invalid"}, 2)) +} + +func TestTree_search(t *testing.T) { + tree := newTree() + + tree.insertNode("/path/test", []string{http.MethodGet}, nil, testHandlerFunc) + + testCases := []struct { + name, method, path string + expectedErr error + expectedParams Params + }{ + {name: "ok", method: http.MethodGet, path: "/path/test", expectedErr: nil, expectedParams: Params{}}, + {name: "not_found", method: http.MethodGet, path: "/path/test2", expectedErr: errNotFound, expectedParams: Params{}}, + {name: "method_not_allowed", method: http.MethodPost, path: "/path/test", expectedErr: errMethodNotAllowed, expectedParams: Params{}}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + hm, params, err := tree.search(tc.path, tc.method) + + assert.ErrorIs(t, err, tc.expectedErr) + assert.Equal(t, tc.expectedParams, params) + + if tc.expectedErr == nil { + assert.NotEmpty(t, hm) + } else { + assert.Empty(t, hm) + } + }) + } +}