- 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
526 lines
14 KiB
Go
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
|
|
}
|