- 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
371 lines
14 KiB
Go
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")
|
|
}
|
|
}
|