feat(13-01): register overlapping constrained routes in surf
- compile groups routes ServeMux refuses side by side into overlap families
and registers each under one generated method-less pattern
- the family handler tries members in registration order on literals and
Where constraints, sets their path values and runs their own wrapped chain
- no match answers the bare 404; a method mismatch answers ServeMux's 405
and Allow for the same table without the overlap
- unsupported shapes (same shape, {name...}, shadowing route) fail at boot
- README and docs/services/routing.md describe the behaviour
This commit is contained in:
370
modules/surf/overlap_test.go
Normal file
370
modules/surf/overlap_test.go
Normal file
@@ -0,0 +1,370 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user