feat(03-03): add typed path params and per-plugin rollback

Compile regex and enum constraints at route registration so malformed and unknown IDs share a 404, and named rollback errors isolate one plugin's history.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jakub Zych
2026-09-17 20:19:59 +02:00
parent a9c3e7690f
commit 8e3bf266d8
10 changed files with 354 additions and 58 deletions

View File

@@ -3,7 +3,6 @@ package surf
import (
"fmt"
"net/http"
"strconv"
"strings"
"git.golem15.com/golem15/summercms/backpack"
@@ -23,11 +22,12 @@ type namedMiddleware struct {
}
type route struct {
pluginID string
method string
path string
handler http.Handler
middleware []string
pluginID string
method string
path string
handler http.Handler
middleware []string
constraints []Constraint
}
// Router compiles group declarations onto net/http ServeMux.
@@ -128,6 +128,62 @@ func (g *Group) Get(path string, handler http.HandlerFunc, middleware ...string)
g.router.add(g.pluginID, g.prefix, g.middleware, path, handler, middleware)
}
// Where attaches a compiled regex constraint to the last route, matching PHP ->where().
func (r *Router) Where(param, pattern string) {
if r == nil {
return
}
c, err := Regex(param, pattern)
if err != nil {
r.compileErr = err
return
}
r.addConstraint(c)
}
func (g *Group) Where(param, pattern string) {
if g == nil || g.router == nil {
return
}
g.router.Where(param, pattern)
}
// WhereIn attaches an allow-listed enum constraint to the last route.
func (r *Router) WhereIn(param string, values ...string) {
if r == nil {
return
}
c, err := Enum(param, values...)
if err != nil {
r.compileErr = err
return
}
r.addConstraint(c)
}
func (g *Group) WhereIn(param string, values ...string) {
if g == nil || g.router == nil {
return
}
g.router.WhereIn(param, values...)
}
func (r *Router) addConstraint(c Constraint) {
if r.compileErr != nil {
return
}
if len(r.routes) == 0 {
r.compileErr = fmt.Errorf("surf: Where(%q) with no route", c.param)
return
}
last := &r.routes[len(r.routes)-1]
if !pathHasParam(last.path, c.param) {
r.compileErr = fmt.Errorf("surf: Where(%q) is not a path parameter of %s", c.param, last.path)
return
}
last.constraints = append(last.constraints, c)
}
func (r *Router) add(pluginID, prefix string, groupMW []string, path string, handler http.HandlerFunc, extra []string) {
full := joinPath(prefix, path)
key := "GET " + full
@@ -163,7 +219,7 @@ func (r *Router) compile() (http.Handler, error) {
}
func (r *Router) wrap(rt route) (http.Handler, error) {
h := rt.handler
h := constrain(rt.handler, rt.constraints)
h = noOpLimit(h)
h = orgSlot(h)
for i := len(rt.middleware) - 1; i >= 0; i-- {
@@ -290,23 +346,6 @@ func noOpLimit(next http.Handler) http.Handler {
return noopLimiter{}.Wrap(next)
}
// IntParam returns a positive integer path value. Missing or malformed ids
// are false so callers can 404 both unknown and non-integer values.
func IntParam(r *http.Request, name string) (int64, bool) {
if r == nil {
return 0, false
}
raw := r.PathValue(name)
if raw == "" {
return 0, false
}
n, err := strconv.ParseInt(raw, 10, 64)
if err != nil || n < 1 {
return 0, false
}
return n, true
}
func joinPath(prefix, path string) string {
prefix = strings.TrimSuffix(prefix, "/")
path = strings.TrimSpace(path)