package surf import ( "net/http" "strings" "testing" ) // rackTable is a three-member family joined transitively: token/{token} // conflicts with {rackId}/items, which conflicts with items/{id}, while // token/{token} and items/{id} differ in a literal and never conflict. var rackTable = []overlapRoute{ {name: "rack-token", method: "GET", path: "/racks/token/{token}"}, {name: "rack-items", method: "GET", path: "/racks/{rackId}/items", where: map[string]string{"rackId": "[0-9]+"}}, {name: "rack-items-delete", method: "DELETE", path: "/racks/{rackId}/items", where: map[string]string{"rackId": "[0-9]+"}}, {name: "item-show", method: "GET", path: "/racks/items/{id}", where: map[string]string{"id": "[0-9]+"}}, {name: "item-put", method: "PUT", path: "/racks/items/{id}", where: map[string]string{"id": "[0-9]+"}}, {name: "rack-create", method: "POST", path: "/racks/{kind}/new"}, } func TestOverlapFamilyOfThree(t *testing.T) { // The conflict closure holds the three GET routes; the other methods // join the family at compile time through its generated pattern. r := buildOverlapRouter(t, rackTable) fams, err := overlapFamilies(r.routes) if err != nil { t.Fatal(err) } if len(fams) != 1 || fams[0].pattern != "/racks/{surfOverlap1}/{surfOverlap2}" { t.Fatalf("families = %+v, want one family on one pattern", fams) } var members []string for _, idx := range fams[0].members { members = append(members, r.routes[idx].method+" "+r.routes[idx].path) } if strings.Join(members, ",") != "GET /racks/token/{token},GET /racks/{rackId}/items,GET /racks/items/{id}" { t.Fatalf("family members = %v", members) } h := compileOverlap(t, rackTable) for _, c := range []struct{ method, path, want string }{ {"GET", "/racks/token/abc", "rack-token token=abc"}, {"GET", "/racks/token/7", "rack-token token=7"}, {"GET", "/racks/7/items", "rack-items rackId=7"}, {"DELETE", "/racks/7/items", "rack-items-delete rackId=7"}, {"GET", "/racks/items/9", "item-show id=9"}, {"PUT", "/racks/items/9", "item-put id=9"}, {"POST", "/racks/steel/new", "rack-create kind=steel"}, } { 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) } } for _, path := range []string{"/racks/items/items", "/racks/x7/items", "/racks/items/9x"} { if rec := serve(h, "GET", path); rec.Code != http.StatusNotFound || rec.Body.String() != "404 page not found\n" { t.Errorf("GET %s = %d %q, want the bare 404", path, rec.Code, rec.Body.String()) } } } func TestOverlapHeadAndAllow(t *testing.T) { h := compileOverlap(t, rackTable) // A GET member answers HEAD, with its own handler. for _, path := range []string{"/racks/token/abc", "/racks/7/items", "/racks/items/9"} { if rec := serve(h, "HEAD", path); rec.Code != http.StatusOK { t.Errorf("HEAD %s = %d, want 200", path, rec.Code) } } // Allow lists the methods of every route that matches the path, sorted, // with HEAD beside GET, as ServeMux does for unrelated routes. for _, c := range []struct{ method, path, allow string }{ {"POST", "/racks/items/9", "GET, HEAD, PUT"}, {"PATCH", "/racks/7/items", "DELETE, GET, HEAD"}, {"PUT", "/racks/token/abc", "GET, HEAD"}, {"GET", "/racks/steel/new", "POST"}, } { rec := serve(h, c.method, c.path) if rec.Code != http.StatusMethodNotAllowed || rec.Header().Get("Allow") != c.allow { t.Errorf("%s %s = %d Allow %q, want 405 %q", c.method, c.path, rec.Code, rec.Header().Get("Allow"), c.allow) } } // A method a member serves, whose constraint fails, is the bare 404, // never a 405 naming the member. if rec := serve(h, "DELETE", "/racks/abc/items"); rec.Code != http.StatusNotFound || rec.Header().Get("Allow") != "" { t.Errorf("DELETE /racks/abc/items = %d Allow %q, want the bare 404", rec.Code, rec.Header().Get("Allow")) } // A path no member and no route matches stays the router's 404. if rec := serve(h, "GET", "/racks/a/b/c"); rec.Code != http.StatusNotFound { t.Errorf("GET /racks/a/b/c = %d, want 404", rec.Code) } } func TestOverlapFamilyAcrossPlugins(t *testing.T) { r := New(nil) register := func(plugin string, rt overlapRoute) { r.BindPlugin(plugin) mw := "mw." + rt.name if err := r.RegisterMiddleware(plugin, mw, markMiddleware(rt.name)); err != nil { t.Fatal(err) } r.Get(rt.path, overlapHandler(rt), mw) for p, re := range rt.where { r.Where(p, re) } } register("acme.alpha", overlapRoute{name: "alpha-token", path: "/bins/token/{token}"}) register("acme.beta", overlapRoute{name: "beta-items", path: "/bins/{binId}/items", where: map[string]string{"binId": "[0-9]+"}}) h, err := r.compile() if err != nil { t.Fatalf("a family across plugins must compile: %v", err) } for _, c := range []struct{ path, body, mw string }{ {"/bins/token/q", "alpha-token token=q", "alpha-token"}, {"/bins/3/items", "beta-items binId=3", "beta-items"}, } { rec := serve(h, "GET", c.path) if rec.Code != http.StatusOK || rec.Body.String() != c.body || strings.Join(rec.Header().Values("X-Mw"), ",") != c.mw { t.Errorf("GET %s = %d %q mw %v, want %q with only %s's middleware", c.path, rec.Code, rec.Body.String(), rec.Header().Values("X-Mw"), c.body, c.mw) } } plugins := map[string]string{} for _, info := range r.Routes() { plugins[info.Pattern] = info.PluginID } if plugins["/bins/token/{token}"] != "acme.alpha" || plugins["/bins/{binId}/items"] != "acme.beta" { t.Fatalf("route table plugins = %v", plugins) } } func TestOverlapUnsupportedShapes(t *testing.T) { for name, table := range map[string][]overlapRoute{ "trailing-slash": { {name: "dir", method: "GET", path: "/bins/token/{token}/"}, {name: "items", method: "GET", path: "/bins/{binId}/items/", where: map[string]string{"binId": "[0-9]+"}}, }, "multi-segment": { {name: "rest", method: "GET", path: "/bins/token/{rest...}"}, {name: "items", method: "GET", path: "/bins/{binId}/items", where: map[string]string{"binId": "[0-9]+"}}, }, } { t.Run(name, func(t *testing.T) { _, err := buildOverlapRouter(t, table).compile() if err == nil || !strings.Contains(err.Error(), "route conflict") || !strings.Contains(err.Error(), `plugin "acme.shelves"`) { t.Fatalf("err = %v, want a route conflict naming both routes and their plugin", err) } }) } } // TestOverlapConstraintFallsThrough: when an earlier member matches the // literals but not its constraint, the family tries the next member // instead of answering for the first one, as Laravel's router walks its // routes in order. func TestOverlapConstraintFallsThrough(t *testing.T) { h := compileOverlap(t, []overlapRoute{ {name: "by-number", method: "GET", path: "/drawers/{id}/open", where: map[string]string{"id": "[0-9]+"}}, {name: "by-name", method: "GET", path: "/drawers/top/{action}"}, }) for _, c := range []struct{ path, want string }{ {"/drawers/top/open", "by-name action=open"}, {"/drawers/7/open", "by-number id=7"}, {"/drawers/top/close", "by-name action=close"}, } { rec := serve(h, "GET", c.path) if rec.Code != http.StatusOK || rec.Body.String() != c.want { t.Errorf("GET %s = %d %q, want 200 %q", c.path, rec.Code, rec.Body.String(), c.want) } } if rec := serve(h, "GET", "/drawers/x/open"); rec.Code != http.StatusNotFound { t.Errorf("GET /drawers/x/open = %d, want 404", rec.Code) } }