Files
summercms/modules/surf/overlap_test.go
Jakub Zych fbdeb20126 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
2026-10-03 06:22:20 +02:00

371 lines
14 KiB
Go

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")
}
}