Files
summercms/modules/surf/overlap.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

526 lines
14 KiB
Go

package surf
import (
"fmt"
"net/http"
"net/url"
"sort"
"strings"
)
// Overlapping constrained routes.
//
// Laravel matches routes in registration order and checks ->where()
// constraints while matching, so `GET /shelves/items/{id}` (id numeric) and
// `GET /shelves/{shelfId}/follow` live side by side: no request satisfies
// both. ServeMux only looks at the patterns and refuses the pair at
// registration because neither is more specific than the other.
//
// compile therefore groups such routes into overlap families (the transitive
// closure of "ServeMux refuses these two") and registers each family once,
// under a generated method-less pattern with a wildcard wherever the members
// differ. The family handler walks the members in registration order and runs
// the first whose literal segments and constraints match, with its own path
// values and its own fully wrapped handler. Everything outside a family
// registers exactly as before.
// pathSeg is one segment of a simple route path: a literal or a
// single-segment wildcard.
type pathSeg struct {
raw string // segment text as written in the route
lit string // unescaped literal; empty for a wildcard
param string // wildcard name; empty for a literal
}
// overlapFamily is one group of routes registered behind a single pattern.
type overlapFamily struct {
members []int // indexes into Router.routes, in registration order
pattern string // generated method-less ServeMux pattern
wild []string // per segment: generated wildcard name, or "" for a shared literal
}
const overlapProbeSegment = "surf~overlap~probe"
var noopHandler = http.NotFoundHandler()
// parseSimplePath splits a route path into literal and single-segment
// wildcard segments. It reports false for every other shape: the root, a
// trailing-slash subtree, an empty segment, a {name...} or {$} wildcard, a
// host, or a malformed escape.
func parseSimplePath(path string) ([]pathSeg, bool) {
if !strings.HasPrefix(path, "/") || path == "/" || strings.HasSuffix(path, "/") {
return nil, false
}
parts := strings.Split(path[1:], "/")
segs := make([]pathSeg, len(parts))
for i, part := range parts {
if part == "" {
return nil, false
}
if strings.HasPrefix(part, "{") && strings.HasSuffix(part, "}") {
name := part[1 : len(part)-1]
if !isWildcardName(name) {
return nil, false
}
segs[i] = pathSeg{raw: part, param: name}
continue
}
if strings.ContainsAny(part, "{}") {
return nil, false
}
lit, err := url.PathUnescape(part)
if err != nil {
return nil, false
}
segs[i] = pathSeg{raw: part, lit: lit}
}
return segs, true
}
func isWildcardName(name string) bool {
if name == "" {
return false
}
for i, c := range name {
switch {
case c == '_', c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z':
case i > 0 && c >= '0' && c <= '9':
default:
return false
}
}
return true
}
// tryHandle registers pattern on mux and reports whether ServeMux accepted it.
func tryHandle(mux *http.ServeMux, pattern string, h http.Handler) (ok bool) {
defer func() {
if recover() != nil {
ok = false
}
}()
mux.Handle(pattern, h)
return true
}
// muxConflict asks ServeMux itself whether two valid patterns can share a mux.
func muxConflict(a, b string) bool {
if !tryHandle(http.NewServeMux(), a, noopHandler) || !tryHandle(http.NewServeMux(), b, noopHandler) {
return false
}
mux := http.NewServeMux()
mux.Handle(a, noopHandler)
return !tryHandle(mux, b, noopHandler)
}
// mayOverlap is a cheap pre-filter: two simple paths with a different segment
// count or two different literals at one position can never match the same
// request. Anything else goes to muxConflict.
func mayOverlap(a, b string) bool {
sa, okA := parseSimplePath(a)
sb, okB := parseSimplePath(b)
if !okA || !okB {
return true
}
if len(sa) != len(sb) {
return false
}
for i := range sa {
if sa[i].param == "" && sb[i].param == "" && sa[i].lit != sb[i].lit {
return false
}
}
return true
}
func routePattern(rt route) string { return rt.method + " " + rt.path }
func routeConflict(a, b route) bool {
return a.method == b.method && mayOverlap(a.path, b.path) && muxConflict(routePattern(a), routePattern(b))
}
type unionFind []int
func newUnionFind(n int) unionFind {
u := make(unionFind, n)
for i := range u {
u[i] = i
}
return u
}
func (u unionFind) find(i int) int {
for u[i] != i {
u[i] = u[u[i]]
i = u[i]
}
return i
}
// union joins two sets and reports whether they were separate.
func (u unionFind) union(a, b int) bool {
ra, rb := u.find(a), u.find(b)
if ra == rb {
return false
}
if ra < rb {
u[rb] = ra
} else {
u[ra] = rb
}
return true
}
func (u unionFind) groups() [][]int {
byRoot := map[int][]int{}
var roots []int
for i := range u {
root := u.find(i)
if _, ok := byRoot[root]; !ok {
roots = append(roots, root)
}
byRoot[root] = append(byRoot[root], i)
}
sort.Ints(roots)
var out [][]int
for _, root := range roots {
if members := byRoot[root]; len(members) > 1 {
out = append(out, members)
}
}
return out
}
// overlapFamilies finds every overlap family in routes. A route whose
// pattern ServeMux rejects for another reason than a conflict is left alone
// so handleRoute reports it as before.
func overlapFamilies(routes []route) ([]overlapFamily, error) {
uf := newUnionFind(len(routes))
probe := http.NewServeMux()
var outside []int // routes the probe mux refused
for j, rt := range routes {
pat := routePattern(rt)
if !tryHandle(http.NewServeMux(), pat, noopHandler) {
continue
}
if tryHandle(probe, pat, noopHandler) {
for _, i := range outside {
if routeConflict(routes[i], rt) {
uf.union(i, j)
}
}
continue
}
for i := 0; i < j; i++ {
if routeConflict(routes[i], rt) {
uf.union(i, j)
}
}
outside = append(outside, j)
}
if len(uf.groups()) == 0 {
return nil, nil
}
// A family's generated pattern may itself overlap another route or
// another family's pattern; fold those in until nothing changes.
for {
groups := uf.groups()
fams := make([]overlapFamily, len(groups))
for fi, members := range groups {
fam, err := generaliseFamily(routes, members)
if err != nil {
return nil, err
}
fams[fi] = fam
}
changed := false
for _, fam := range fams {
root := uf.find(fam.members[0])
for k, rt := range routes {
if uf.find(k) == root || inAnyFamily(uf, fams, k) {
continue
}
if mayOverlap(fam.pattern, rt.path) && muxConflict(fam.pattern, routePattern(rt)) {
changed = uf.union(fam.members[0], k) || changed
}
}
}
for a := 0; a < len(fams); a++ {
for b := a + 1; b < len(fams); b++ {
if mayOverlap(fams[a].pattern, fams[b].pattern) && muxConflict(fams[a].pattern, fams[b].pattern) {
changed = uf.union(fams[a].members[0], fams[b].members[0]) || changed
}
}
}
if !changed {
return fams, nil
}
}
}
func inAnyFamily(uf unionFind, fams []overlapFamily, k int) bool {
root := uf.find(k)
for _, fam := range fams {
if uf.find(fam.members[0]) == root {
return true
}
}
return false
}
func describeRoute(rt route) string {
return fmt.Sprintf("%s %s (plugin %q)", rt.method, rt.path, rt.pluginID)
}
// generaliseFamily validates the member shapes and builds the shared pattern.
func generaliseFamily(routes []route, members []int) (overlapFamily, error) {
parsed := make([][]pathSeg, len(members))
for mi, idx := range members {
segs, ok := parseSimplePath(routes[idx].path)
if !ok {
other := members[0]
if other == idx {
other = members[1]
}
return overlapFamily{}, fmt.Errorf("surf: route conflict for %s and %s: overlapping routes must use literal and single-segment {name} wildcards only",
describeRoute(routes[idx]), describeRoute(routes[other]))
}
parsed[mi] = segs
}
n := len(parsed[0])
for mi := 1; mi < len(parsed); mi++ {
if len(parsed[mi]) != n {
return overlapFamily{}, fmt.Errorf("surf: route conflict for %s and %s: overlapping routes differ in segment count",
describeRoute(routes[members[mi]]), describeRoute(routes[members[0]]))
}
}
// Two routes of one method with the same shape differ only in parameter
// names: the later one could never be reached by registration order.
for a := 0; a < len(members); a++ {
for b := a + 1; b < len(members); b++ {
if routes[members[a]].method == routes[members[b]].method && sameShape(parsed[a], parsed[b]) {
return overlapFamily{}, fmt.Errorf("surf: route conflict for %s and %s: the routes have the same shape",
describeRoute(routes[members[b]]), describeRoute(routes[members[a]]))
}
}
}
wild := make([]string, n)
parts := make([]string, n)
for i := 0; i < n; i++ {
shared := true
for mi := range parsed {
s := parsed[mi][i]
if s.param != "" || s.lit != parsed[0][i].lit {
shared = false
break
}
}
if shared {
parts[i] = parsed[0][i].raw
continue
}
wild[i] = fmt.Sprintf("surfOverlap%d", i)
parts[i] = "{" + wild[i] + "}"
}
return overlapFamily{
members: append([]int(nil), members...),
pattern: "/" + strings.Join(parts, "/"),
wild: wild,
}, nil
}
func sameShape(a, b []pathSeg) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if (a[i].param == "") != (b[i].param == "") {
return false
}
if a[i].param == "" && a[i].lit != b[i].lit {
return false
}
}
return true
}
// familyMember is one route behind a family pattern.
type familyMember struct {
method string
pattern string
segs []pathSeg
constraints []Constraint
handler http.Handler
}
func (m familyMember) pathMatches(vals []string) bool {
for i, s := range m.segs {
if s.param == "" && s.lit != vals[i] {
return false
}
}
return true
}
func (m familyMember) constraintsMatch(vals []string) bool {
for _, c := range m.constraints {
value := ""
for i, s := range m.segs {
if s.param == c.param {
value = vals[i]
break
}
}
if !c.match(value) {
return false
}
}
return true
}
// methodMatches mirrors ServeMux: a GET route also answers HEAD.
func methodMatches(routeMethod, reqMethod string) bool {
return routeMethod == reqMethod || (routeMethod == http.MethodGet && reqMethod == http.MethodHead)
}
// familyDispatch is the handler registered under a family pattern.
type familyDispatch struct {
pattern string
wild []string
lits []string
members []familyMember
methods []string // every method in the router's table, for Allow
mux *http.ServeMux
}
func (f *familyDispatch) ServeHTTP(w http.ResponseWriter, r *http.Request) {
vals := make([]string, len(f.wild))
for i, name := range f.wild {
if name == "" {
vals[i] = f.lits[i]
} else {
vals[i] = r.PathValue(name)
}
}
methodSeen := false
for _, m := range f.members {
if !methodMatches(m.method, r.Method) || !m.pathMatches(vals) {
continue
}
methodSeen = true
if !m.constraintsMatch(vals) {
continue
}
for i, s := range m.segs {
if s.param != "" {
r.SetPathValue(s.param, vals[i])
}
}
r.Pattern = m.pattern
m.handler.ServeHTTP(w, r)
return
}
if methodSeen {
http.NotFound(w, r)
return
}
allow := f.allowed(r, vals)
if len(allow) == 0 {
http.NotFound(w, r)
return
}
w.Header().Set("Allow", strings.Join(allow, ", "))
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
}
// allowed lists the methods ServeMux would put in Allow for this path if the
// table held the members on their own patterns: any other route the mux
// matches for the method, plus members whose literal segments match.
func (f *familyDispatch) allowed(r *http.Request, vals []string) []string {
set := map[string]bool{}
for _, method := range f.methods {
_, pat := f.mux.Handler(&http.Request{Method: method, URL: r.URL, Host: r.Host})
switch pat {
case "":
case f.pattern:
for _, m := range f.members {
if m.method == method && m.pathMatches(vals) {
set[method] = true
break
}
}
default:
set[method] = true
}
}
if set[http.MethodGet] {
set[http.MethodHead] = true
}
out := make([]string, 0, len(set))
for method := range set {
out = append(out, method)
}
sort.Strings(out)
return out
}
// registerOverlapFamilies registers each family pattern on mux, then checks
// that no other route takes a member's requests away from the family.
func registerOverlapFamilies(mux *http.ServeMux, routes []route, handlers []http.Handler, fams []overlapFamily) error {
if len(fams) == 0 {
return nil
}
methodSet := map[string]bool{}
for _, rt := range routes {
methodSet[rt.method] = true
}
methods := make([]string, 0, len(methodSet))
for method := range methodSet {
methods = append(methods, method)
}
sort.Strings(methods)
for _, fam := range fams {
d := &familyDispatch{pattern: fam.pattern, wild: fam.wild, methods: methods, mux: mux}
d.lits = make([]string, len(fam.wild))
for _, idx := range fam.members {
rt := routes[idx]
segs, _ := parseSimplePath(rt.path)
for i, s := range segs {
if fam.wild[i] == "" {
d.lits[i] = s.lit
}
}
d.members = append(d.members, familyMember{
method: rt.method,
pattern: routePattern(rt),
segs: segs,
constraints: rt.constraints,
handler: handlers[idx],
})
}
if !tryHandle(mux, fam.pattern, d) {
first := routes[fam.members[0]]
return fmt.Errorf("surf: route conflict for %s: the overlap pattern %s cannot be registered", describeRoute(first), fam.pattern)
}
}
for _, fam := range fams {
for _, idx := range fam.members {
rt := routes[idx]
segs, _ := parseSimplePath(rt.path)
parts := make([]string, len(segs))
for i, s := range segs {
if s.param != "" {
parts[i] = overlapProbeSegment
} else {
parts[i] = s.raw
}
}
probe := &http.Request{Method: rt.method, URL: &url.URL{Path: "/" + strings.Join(parts, "/")}}
if _, pat := mux.Handler(probe); pat != fam.pattern {
return fmt.Errorf("surf: route conflict for %s: route %s takes its requests before the overlapping routes are tried", describeRoute(rt), pat)
}
}
}
return nil
}