package surf import ( "net/http" "net/http/httptest" "strings" "testing" ) type overlapRoute struct { name string method string path string where map[string]string } // overlapTable mirrors the overlapping shapes an application may port from // routes.php: a literal-segment route beside a numeric one, a three-way // family, two methods sharing one generated pattern, and a literal route // that stays more specific than every family member. var overlapTable = []overlapRoute{ {name: "token-follow", method: "GET", path: "/shelves/token/{token}/follow"}, {name: "token-follow-post", method: "POST", path: "/shelves/token/{token}/follow"}, {name: "shelf-follow", method: "GET", path: "/shelves/{shelfId}/follow", where: map[string]string{"shelfId": "[0-9]+"}}, {name: "shelf-follow-post", method: "POST", path: "/shelves/{shelfId}/follow", where: map[string]string{"shelfId": "[0-9]+"}}, {name: "shelf-unfollow", method: "DELETE", path: "/shelves/{shelfId}/follow", where: map[string]string{"shelfId": "[0-9]+"}}, {name: "item-similar", method: "GET", path: "/shelves/items/similar"}, {name: "item-show", method: "GET", path: "/shelves/items/{id}", where: map[string]string{"id": "[0-9]+"}}, {name: "item-update", method: "PUT", path: "/shelves/items/{id}", where: map[string]string{"id": "[0-9]+"}}, {name: "item-delete", method: "DELETE", path: "/shelves/items/{id}", where: map[string]string{"id": "[0-9]+"}}, {name: "shelf-items", method: "GET", path: "/shelves/{shelfId}/items", where: map[string]string{"shelfId": "[0-9]+"}}, {name: "shelf-item", method: "GET", path: "/shelves/{shelfId}/items/{itemId}", where: map[string]string{"shelfId": "[0-9]+", "itemId": "[0-9]+"}}, } func overlapParams(path string) []string { var out []string for _, seg := range strings.Split(path, "/") { if strings.HasPrefix(seg, "{") && strings.HasSuffix(seg, "}") { out = append(out, strings.Trim(seg, "{}")) } } return out } func overlapHandler(rt overlapRoute) http.HandlerFunc { params := overlapParams(rt.path) return func(w http.ResponseWriter, r *http.Request) { parts := []string{rt.name} for _, p := range params { parts = append(parts, p+"="+r.PathValue(p)) } _, _ = w.Write([]byte(strings.Join(parts, " "))) } } func markMiddleware(name string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Add("X-Mw", name) next.ServeHTTP(w, r) }) } } func buildOverlapRouter(t *testing.T, table []overlapRoute) *Router { t.Helper() r := New(nil) r.BindPlugin("acme.shelves") for _, rt := range table { mw := "mw." + rt.name if err := r.RegisterMiddleware("acme.shelves", mw, markMiddleware(rt.name)); err != nil { t.Fatal(err) } h := overlapHandler(rt) switch rt.method { case "GET": r.Get(rt.path, h, mw) case "POST": r.Post(rt.path, h, mw) case "PUT": r.Put(rt.path, h, mw) case "PATCH": r.Patch(rt.path, h, mw) case "DELETE": r.Delete(rt.path, h, mw) default: t.Fatalf("method %s", rt.method) } for param, re := range rt.where { r.Where(param, re) } } return r } func compileOverlap(t *testing.T, table []overlapRoute) http.Handler { t.Helper() h, err := buildOverlapRouter(t, table).compile() if err != nil { t.Fatalf("compile: %v", err) } return h } // referenceMux registers the table on a plain ServeMux, without the named // conflicting partners, as the router would have done before overlap support. func referenceMux(t *testing.T, table []overlapRoute, drop ...string) *http.ServeMux { t.Helper() skip := map[string]bool{} for _, d := range drop { skip[d] = true } mux := http.NewServeMux() for _, rt := range table { if skip[rt.name] { continue } mux.Handle(rt.method+" "+rt.path, overlapHandler(rt)) } return mux } func serve(h http.Handler, method, path string) *httptest.ResponseRecorder { rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(method, path, nil)) return rec } func TestOverlappingConstrainedRoutes(t *testing.T) { t.Run("boot", func(t *testing.T) { r := buildOverlapRouter(t, overlapTable) if _, err := r.compile(); err != nil { t.Fatalf("compile: %v", err) } fams, err := overlapFamilies(r.routes) if err != nil { t.Fatal(err) } // One four-segment GET family and one three-segment family holding // the GET and DELETE members (both methods generalise to one shape). if len(fams) != 2 { t.Fatalf("families = %d, want 2: %+v", len(fams), fams) } got := map[string]int{} for _, f := range fams { got[f.pattern] = len(f.members) } if got["/shelves/{surfOverlap1}/{surfOverlap2}"] != 5 || got["/shelves/{surfOverlap1}/{surfOverlap2}/{surfOverlap3}"] != 2 { t.Fatalf("family patterns = %v", got) } }) h := compileOverlap(t, overlapTable) t.Run("dispatch", func(t *testing.T) { cases := []struct{ method, path, want string }{ {"GET", "/shelves/token/abc/follow", "token-follow token=abc"}, {"GET", "/shelves/token/5/follow", "token-follow token=5"}, {"POST", "/shelves/token/abc/follow", "token-follow-post token=abc"}, {"GET", "/shelves/5/items/6", "shelf-item shelfId=5 itemId=6"}, {"GET", "/shelves/5/follow", "shelf-follow shelfId=5"}, {"POST", "/shelves/5/follow", "shelf-follow-post shelfId=5"}, {"DELETE", "/shelves/5/follow", "shelf-unfollow shelfId=5"}, {"GET", "/shelves/items/7", "item-show id=7"}, {"PUT", "/shelves/items/7", "item-update id=7"}, {"DELETE", "/shelves/items/7", "item-delete id=7"}, {"GET", "/shelves/5/items", "shelf-items shelfId=5"}, {"GET", "/shelves/items/similar", "item-similar"}, } for _, c := range cases { rec := serve(h, c.method, c.path) if rec.Code != http.StatusOK || rec.Body.String() != c.want { t.Errorf("%s %s = %d %q, want 200 %q", c.method, c.path, rec.Code, rec.Body.String(), c.want) } } if rec := serve(h, "HEAD", "/shelves/items/7"); rec.Code != http.StatusOK { t.Errorf("HEAD /shelves/items/7 = %d, want 200 (GET answers HEAD)", rec.Code) } }) t.Run("constraint-404", func(t *testing.T) { for _, c := range []struct{ method, path string }{ {"GET", "/shelves/items/follow"}, {"GET", "/shelves/0x1/items"}, {"GET", "/shelves/abc/items/1"}, {"GET", "/shelves/5/items/x"}, {"DELETE", "/shelves/items/similar"}, {"DELETE", "/shelves/abc/follow"}, } { rec := serve(h, c.method, c.path) if rec.Code != http.StatusNotFound || rec.Body.String() != "404 page not found\n" || rec.Header().Get("Content-Type") != "text/plain; charset=utf-8" { t.Errorf("%s %s = %d %q %q, want the bare 404", c.method, c.path, rec.Code, rec.Header().Get("Content-Type"), rec.Body.String()) } } }) t.Run("method-405", func(t *testing.T) { cases := []struct { method, path string drop []string }{ {"POST", "/shelves/items/7", []string{"shelf-follow", "shelf-unfollow", "shelf-items", "shelf-item"}}, {"PATCH", "/shelves/5/follow", []string{"item-show", "item-delete", "shelf-item"}}, {"PUT", "/shelves/5/items", []string{"item-show", "item-delete", "shelf-item"}}, {"POST", "/shelves/5/items/6", []string{"token-follow", "item-show", "item-delete"}}, {"DELETE", "/shelves/token/abc/follow", []string{"shelf-item", "item-show", "item-delete"}}, } for _, c := range cases { got := serve(h, c.method, c.path) want := serve(referenceMux(t, overlapTable, c.drop...), c.method, c.path) if want.Code != http.StatusMethodNotAllowed { t.Fatalf("reference %s %s = %d, the case must be a 405", c.method, c.path, want.Code) } if got.Code != want.Code || got.Header().Get("Allow") != want.Header().Get("Allow") || got.Body.String() != want.Body.String() { t.Errorf("%s %s = %d Allow %q %q, want %d Allow %q %q", c.method, c.path, got.Code, got.Header().Get("Allow"), got.Body.String(), want.Code, want.Header().Get("Allow"), want.Body.String()) } } }) t.Run("unknown-method-404", func(t *testing.T) { for _, c := range []struct { method, path string drop []string }{ {"POST", "/shelves/abc/def", []string{"item-show", "item-delete", "shelf-item"}}, {"PATCH", "/shelves/abc/def/ghi", []string{"token-follow", "item-show", "item-delete"}}, {"GET", "/shelves/abc/def", []string{"item-show", "item-delete", "shelf-item"}}, } { got := serve(h, c.method, c.path) want := serve(referenceMux(t, overlapTable, c.drop...), c.method, c.path) if got.Code != http.StatusNotFound || want.Code != http.StatusNotFound || got.Header().Get("Allow") != "" { t.Errorf("%s %s = %d (reference %d) Allow %q, want 404 without Allow", c.method, c.path, got.Code, want.Code, got.Header().Get("Allow")) } } }) t.Run("middleware", func(t *testing.T) { for _, c := range []struct{ method, path, own string }{ {"GET", "/shelves/items/7", "item-show"}, {"GET", "/shelves/5/follow", "shelf-follow"}, {"GET", "/shelves/5/items", "shelf-items"}, {"DELETE", "/shelves/5/follow", "shelf-unfollow"}, {"GET", "/shelves/token/t/follow", "token-follow"}, } { rec := serve(h, c.method, c.path) if got := rec.Header().Values("X-Mw"); len(got) != 1 || got[0] != c.own { t.Errorf("%s %s middleware = %v, want only %s", c.method, c.path, got, c.own) } } }) t.Run("route-table", func(t *testing.T) { r := buildOverlapRouter(t, overlapTable) if _, err := r.compile(); err != nil { t.Fatal(err) } infos := r.Routes() if len(infos) != len(overlapTable) { t.Fatalf("Routes() = %d entries, want %d", len(infos), len(overlapTable)) } for i, rt := range overlapTable { if infos[i].Method != rt.method || infos[i].Pattern != rt.path || len(infos[i].Middleware) != 1 || infos[i].Middleware[0] != "mw."+rt.name { t.Errorf("Routes()[%d] = %+v, want %s %s [mw.%s]", i, infos[i], rt.method, rt.path, rt.name) } if strings.Contains(infos[i].Pattern, "surfOverlap") { t.Errorf("Routes() leaks a family pattern: %s", infos[i].Pattern) } } }) t.Run("unsupported", func(t *testing.T) { _, err := buildOverlapRouter(t, []overlapRoute{ {name: "files", method: "GET", path: "/files/{rest...}"}, {name: "readme", method: "GET", path: "/{dir}/readme", where: map[string]string{"dir": "[a-z]+"}}, }).compile() if err == nil || !strings.Contains(err.Error(), "/files/{rest...}") || !strings.Contains(err.Error(), "/{dir}/readme") { t.Fatalf("err = %v, want a compile error naming both routes", err) } }) t.Run("same-shape", func(t *testing.T) { _, err := buildOverlapRouter(t, []overlapRoute{ {name: "by-id", method: "GET", path: "/shelves/{id}", where: map[string]string{"id": "[0-9]+"}}, {name: "by-slug", method: "GET", path: "/shelves/{slug}", where: map[string]string{"slug": "[a-z]+"}}, }).compile() if err == nil || !strings.Contains(err.Error(), "route conflict") || !strings.Contains(err.Error(), "same shape") { t.Fatalf("err = %v, want a same-shape conflict", err) } }) t.Run("shadowed", func(t *testing.T) { _, err := buildOverlapRouter(t, []overlapRoute{ {name: "any", method: "GET", path: "/shelves/{a}/{b}"}, {name: "item-show", method: "GET", path: "/shelves/items/{id}", where: map[string]string{"id": "[0-9]+"}}, {name: "shelf-follow", method: "GET", path: "/shelves/{shelfId}/follow", where: map[string]string{"shelfId": "[0-9]+"}}, }).compile() if err == nil || !strings.Contains(err.Error(), "takes its requests") { t.Fatalf("err = %v, want a shadowing error", err) } }) t.Run("pattern-merge", func(t *testing.T) { // The POST route conflicts with no member, only with the family's // generated method-less pattern, so it joins the family. table := []overlapRoute{ {name: "item-show", method: "GET", path: "/shelves/items/{id}", where: map[string]string{"id": "[0-9]+"}}, {name: "shelf-follow", method: "GET", path: "/shelves/{shelfId}/follow", where: map[string]string{"shelfId": "[0-9]+"}}, {name: "any-similar", method: "POST", path: "/{kind}/items/similar"}, } h := compileOverlap(t, table) for _, c := range []struct { method, path string code int want string }{ {"POST", "/shelves/items/similar", 200, "any-similar kind=shelves"}, {"POST", "/racks/items/similar", 200, "any-similar kind=racks"}, {"GET", "/shelves/items/4", 200, "item-show id=4"}, {"GET", "/shelves/4/follow", 200, "shelf-follow shelfId=4"}, } { rec := serve(h, c.method, c.path) if rec.Code != c.code || rec.Body.String() != c.want { t.Errorf("%s %s = %d %q, want %d %q", c.method, c.path, rec.Code, rec.Body.String(), c.code, c.want) } } }) t.Run("no-family", func(t *testing.T) { fams, err := overlapFamilies(buildOverlapRouter(t, []overlapRoute{ {name: "a", method: "GET", path: "/shelves/{id}"}, {name: "b", method: "GET", path: "/shelves/{id}/items"}, {name: "c", method: "POST", path: "/shelves/{id}"}, }).routes) if err != nil || len(fams) != 0 { t.Fatalf("families = %v, %v; want none", fams, err) } }) } func TestParseSimplePath(t *testing.T) { for _, c := range []struct { path string ok bool }{ {"/a/{b}/c", true}, {"/a%20b/{_x1}", true}, {"/", false}, {"/a/", false}, {"/a//b", false}, {"/a/{b...}", false}, {"/a/{$}", false}, {"/a/x{b}", false}, {"/a/{1b}", false}, {"/a/%zz", false}, {"host/a", false}, } { if _, ok := parseSimplePath(c.path); ok != c.ok { t.Errorf("parseSimplePath(%q) ok = %v, want %v", c.path, ok, c.ok) } } if segs, _ := parseSimplePath("/a%20b/{x}"); segs[0].lit != "a b" || segs[0].raw != "a%20b" || segs[1].param != "x" { t.Errorf("segments = %+v", segs) } if muxConflict("GET /a/{", "GET /a/b") { t.Error("an invalid pattern is not a conflict") } }