feat(06-03): add raw groups, house middleware, and route:list

- GroupRaw plus sticky raw inheritance and registration-time house-envelope refusal via pact.HasHouseMiddleware
- Recover on raw routes writes a bare 500; non-raw keeps the house JSON body
- Router.Routes() and surf.RouteListCommand; generated main registers route:list
This commit is contained in:
Jakub Zych
2026-09-19 19:55:32 +02:00
parent 42b8295cc1
commit fa7e6d1870
8 changed files with 391 additions and 22 deletions

View File

@@ -111,6 +111,7 @@ func generateMain(m Manifest) ([]byte, error) {
b.WriteString("\t}\n") b.WriteString("\t}\n")
b.WriteString("\tcommands := lagoon.RuntimeCommands(app, plugins)\n") b.WriteString("\tcommands := lagoon.RuntimeCommands(app, plugins)\n")
b.WriteString("\tcommands = append(commands, surf.ServeCommand(app, plugins))\n") b.WriteString("\tcommands = append(commands, surf.ServeCommand(app, plugins))\n")
b.WriteString("\tcommands = append(commands, surf.RouteListCommand(app, plugins))\n")
b.WriteString("\tfor _, plugin := range plugins {\n") b.WriteString("\tfor _, plugin := range plugins {\n")
b.WriteString("\t\tif hasCommands, ok := plugin.(pact.HasCommands); ok {\n") b.WriteString("\t\tif hasCommands, ok := plugin.(pact.HasCommands); ok {\n")
b.WriteString("\t\t\tcommands = append(commands, hasCommands.Commands()...)\n") b.WriteString("\t\t\tcommands = append(commands, hasCommands.Commands()...)\n")

View File

@@ -101,6 +101,9 @@ func TestGenerateStableQuotedImportsInManifestOrder(t *testing.T) {
if !bytes.Contains(mainSrc, []byte("surf.ServeCommand")) { if !bytes.Contains(mainSrc, []byte("surf.ServeCommand")) {
t.Fatalf("main does not register serve:\n%s", mainSrc) t.Fatalf("main does not register serve:\n%s", mainSrc)
} }
if !bytes.Contains(mainSrc, []byte("surf.RouteListCommand")) {
t.Fatalf("main does not register route:list:\n%s", mainSrc)
}
if bytes.Contains(mainSrc, []byte("examples/hello")) { if bytes.Contains(mainSrc, []byte("examples/hello")) {
t.Fatal("generated main hard-codes examples/hello") t.Fatal("generated main hard-codes examples/hello")
} }

View File

@@ -42,9 +42,25 @@ type HasMiddlewareFactories interface {
MiddlewareFactories() map[string]func(param string) Middleware MiddlewareFactories() map[string]func(param string) Middleware
} }
// HasHouseMiddleware is implemented by plugins that register middleware
// tagged as house-envelope/error handling -- refused inside a raw group
// (D-16). This is the ONLY way a plugin declares a house-tagged name:
// plugins never call a Router.Register* method directly (there is no such
// call site anywhere in this codebase -- RegisterMiddleware/
// RegisterMiddlewareFactory/RegisterHouseMiddleware are all called
// exclusively from surf.Assemble/BuildRouter's plugin loop, the same way
// HasMiddleware's Middlewares() map is today). A name present in both
// Middlewares() and HouseMiddlewares() (from the same or a different
// plugin) fails boot with the existing duplicate-name error, since both are
// registered into the same underlying name table.
type HasHouseMiddleware interface {
HouseMiddlewares() map[string]Middleware
}
// Router is the Laravel-like group builder implemented by surf. // Router is the Laravel-like group builder implemented by surf.
type Router interface { type Router interface {
Group(prefix string, middleware []string, fn func(Router)) Group(prefix string, middleware []string, fn func(Router))
GroupRaw(prefix string, middleware []string, fn func(Router))
Get(path string, handler http.HandlerFunc, middleware ...string) Get(path string, handler http.HandlerFunc, middleware ...string)
Post(path string, handler http.HandlerFunc, middleware ...string) Post(path string, handler http.HandlerFunc, middleware ...string)
Put(path string, handler http.HandlerFunc, middleware ...string) Put(path string, handler http.HandlerFunc, middleware ...string)
@@ -127,6 +143,6 @@ type OptionalMessage interface {
// //
// The kernel type-asserts HasConfig (party, before Register), HasCommands // The kernel type-asserts HasConfig (party, before Register), HasCommands
// (generated app main, after Boot), HasMigrations (lagoon migrate), and // (generated app main, after Boot), HasMigrations (lagoon migrate), and
// HasMiddleware/HasMiddlewareFactories/HasRoutes (surf assemble). // HasMiddleware/HasMiddlewareFactories/HasHouseMiddleware/HasRoutes
// surf.BucketProvider is type-asserted in Assemble (not a pact interface: // (surf assemble). surf.BucketProvider is type-asserted in Assemble/
// pact cannot import surf without a cycle). // BuildRouter (not a pact interface: pact cannot import surf without a cycle).

39
surf/routelist_command.go Normal file
View File

@@ -0,0 +1,39 @@
package surf
import (
"context"
"strconv"
"strings"
"git.golem15.com/golem15/summercms/backpack"
"git.golem15.com/golem15/summercms/bonfire"
"git.golem15.com/golem15/summercms/party"
)
// RouteListCommand builds the router the same way ServeCommand does, then
// renders Routes() as a table instead of serving.
func RouteListCommand(app *backpack.App, plugins []party.Plugin) bonfire.Command {
return bonfire.Command{
Name: "route:list",
Description: "List registered HTTP routes",
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
r, err := BuildRouter(app, plugins)
if err != nil {
return err
}
infos := r.Routes()
rows := make([][]string, 0, len(infos))
for _, rt := range infos {
rows = append(rows, []string{
rt.Method,
rt.Pattern,
rt.PluginID,
strings.Join(rt.Middleware, ","),
strconv.FormatBool(rt.Raw),
})
}
out.Table([]string{"Method", "Pattern", "Plugin", "Middleware", "Raw"}, rows)
return nil
},
}
}

View File

@@ -18,13 +18,15 @@ func Use(names ...string) []string {
} }
type namedMiddleware struct { type namedMiddleware struct {
pluginID string pluginID string
fn pact.Middleware fn pact.Middleware
houseTagged bool
} }
type namedMiddlewareFactory struct { type namedMiddlewareFactory struct {
pluginID string pluginID string
fn func(param string) pact.Middleware fn func(param string) pact.Middleware
houseTagged bool
} }
type route struct { type route struct {
@@ -34,6 +36,7 @@ type route struct {
handler http.Handler handler http.Handler
middleware []string middleware []string
constraints []Constraint constraints []Constraint
raw bool
} }
// Router compiles group declarations onto net/http ServeMux. // Router compiles group declarations onto net/http ServeMux.
@@ -61,6 +64,7 @@ type Group struct {
pluginID string pluginID string
prefix string prefix string
middleware []string middleware []string
raw bool
} }
// New returns an empty router. // New returns an empty router.
@@ -75,6 +79,17 @@ func New(origins []string) *Router {
// RegisterMiddleware stores a named wrapper. Duplicate names fail. // RegisterMiddleware stores a named wrapper. Duplicate names fail.
func (r *Router) RegisterMiddleware(pluginID, name string, fn pact.Middleware) error { func (r *Router) RegisterMiddleware(pluginID, name string, fn pact.Middleware) error {
return r.registerNamed(pluginID, name, false, fn)
}
// RegisterHouseMiddleware stores a named wrapper tagged as house-envelope/error
// handling. Raw groups refuse these names at wrap/Assemble time. Called only
// from BuildRouter's HasHouseMiddleware plugin loop.
func (r *Router) RegisterHouseMiddleware(pluginID, name string, fn pact.Middleware) error {
return r.registerNamed(pluginID, name, true, fn)
}
func (r *Router) registerNamed(pluginID, name string, tagged bool, fn pact.Middleware) error {
if r == nil { if r == nil {
return fmt.Errorf("surf: router is nil") return fmt.Errorf("surf: router is nil")
} }
@@ -84,7 +99,7 @@ func (r *Router) RegisterMiddleware(pluginID, name string, fn pact.Middleware) e
if existing, ok := r.named[name]; ok { if existing, ok := r.named[name]; ok {
return fmt.Errorf("surf: middleware %q already registered by %s", name, existing.pluginID) return fmt.Errorf("surf: middleware %q already registered by %s", name, existing.pluginID)
} }
r.named[name] = namedMiddleware{pluginID: pluginID, fn: fn} r.named[name] = namedMiddleware{pluginID: pluginID, fn: fn, houseTagged: tagged}
return nil return nil
} }
@@ -92,6 +107,17 @@ func (r *Router) RegisterMiddleware(pluginID, name string, fn pact.Middleware) e
// At wrap time a name not found in r.named is split on the first ':' and // At wrap time a name not found in r.named is split on the first ':' and
// the base is looked up here. Duplicate factory names fail. // the base is looked up here. Duplicate factory names fail.
func (r *Router) RegisterMiddlewareFactory(pluginID, name string, fn func(param string) pact.Middleware) error { func (r *Router) RegisterMiddlewareFactory(pluginID, name string, fn func(param string) pact.Middleware) error {
return r.registerNamedFactory(pluginID, name, false, fn)
}
// RegisterHouseMiddlewareFactory stores a parameterized house-envelope/error
// factory. No plugin-facing capability wires this yet; it exists as
// infrastructure for a future house-tagged parameterized consumer.
func (r *Router) RegisterHouseMiddlewareFactory(pluginID, name string, fn func(param string) pact.Middleware) error {
return r.registerNamedFactory(pluginID, name, true, fn)
}
func (r *Router) registerNamedFactory(pluginID, name string, tagged bool, fn func(param string) pact.Middleware) error {
if r == nil { if r == nil {
return fmt.Errorf("surf: router is nil") return fmt.Errorf("surf: router is nil")
} }
@@ -101,7 +127,7 @@ func (r *Router) RegisterMiddlewareFactory(pluginID, name string, fn func(param
if existing, ok := r.factories[name]; ok { if existing, ok := r.factories[name]; ok {
return fmt.Errorf("surf: middleware factory %q already registered by %s", name, existing.pluginID) return fmt.Errorf("surf: middleware factory %q already registered by %s", name, existing.pluginID)
} }
r.factories[name] = namedMiddlewareFactory{pluginID: pluginID, fn: fn} r.factories[name] = namedMiddlewareFactory{pluginID: pluginID, fn: fn, houseTagged: tagged}
return nil return nil
} }
@@ -113,6 +139,14 @@ func (r *Router) BindPlugin(id string) {
} }
func (r *Router) Group(prefix string, middleware []string, fn func(pact.Router)) { func (r *Router) Group(prefix string, middleware []string, fn func(pact.Router)) {
r.openGroup(prefix, middleware, false, fn)
}
func (r *Router) GroupRaw(prefix string, middleware []string, fn func(pact.Router)) {
r.openGroup(prefix, middleware, true, fn)
}
func (r *Router) openGroup(prefix string, middleware []string, raw bool, fn func(pact.Router)) {
if r == nil || fn == nil { if r == nil || fn == nil {
return return
} }
@@ -121,6 +155,7 @@ func (r *Router) Group(prefix string, middleware []string, fn func(pact.Router))
pluginID: r.pluginID, pluginID: r.pluginID,
prefix: joinPath(r.prefix, prefix), prefix: joinPath(r.prefix, prefix),
middleware: append([]string{}, r.middleware...), middleware: append([]string{}, r.middleware...),
raw: raw,
} }
g.middleware = append(g.middleware, middleware...) g.middleware = append(g.middleware, middleware...)
fn(g) fn(g)
@@ -130,38 +165,46 @@ func (r *Router) Get(path string, handler http.HandlerFunc, middleware ...string
if r == nil { if r == nil {
return return
} }
r.add(r.pluginID, r.prefix, r.middleware, http.MethodGet, path, handler, middleware) r.add(r.pluginID, r.prefix, r.middleware, http.MethodGet, path, handler, middleware, false)
} }
func (r *Router) Post(path string, handler http.HandlerFunc, middleware ...string) { func (r *Router) Post(path string, handler http.HandlerFunc, middleware ...string) {
if r == nil { if r == nil {
return return
} }
r.add(r.pluginID, r.prefix, r.middleware, http.MethodPost, path, handler, middleware) r.add(r.pluginID, r.prefix, r.middleware, http.MethodPost, path, handler, middleware, false)
} }
func (r *Router) Put(path string, handler http.HandlerFunc, middleware ...string) { func (r *Router) Put(path string, handler http.HandlerFunc, middleware ...string) {
if r == nil { if r == nil {
return return
} }
r.add(r.pluginID, r.prefix, r.middleware, http.MethodPut, path, handler, middleware) r.add(r.pluginID, r.prefix, r.middleware, http.MethodPut, path, handler, middleware, false)
} }
func (r *Router) Patch(path string, handler http.HandlerFunc, middleware ...string) { func (r *Router) Patch(path string, handler http.HandlerFunc, middleware ...string) {
if r == nil { if r == nil {
return return
} }
r.add(r.pluginID, r.prefix, r.middleware, http.MethodPatch, path, handler, middleware) r.add(r.pluginID, r.prefix, r.middleware, http.MethodPatch, path, handler, middleware, false)
} }
func (r *Router) Delete(path string, handler http.HandlerFunc, middleware ...string) { func (r *Router) Delete(path string, handler http.HandlerFunc, middleware ...string) {
if r == nil { if r == nil {
return return
} }
r.add(r.pluginID, r.prefix, r.middleware, http.MethodDelete, path, handler, middleware) r.add(r.pluginID, r.prefix, r.middleware, http.MethodDelete, path, handler, middleware, false)
} }
func (g *Group) Group(prefix string, middleware []string, fn func(pact.Router)) { func (g *Group) Group(prefix string, middleware []string, fn func(pact.Router)) {
g.openGroup(prefix, middleware, g.raw, fn)
}
func (g *Group) GroupRaw(prefix string, middleware []string, fn func(pact.Router)) {
g.openGroup(prefix, middleware, true, fn)
}
func (g *Group) openGroup(prefix string, middleware []string, raw bool, fn func(pact.Router)) {
if g == nil || g.router == nil || fn == nil { if g == nil || g.router == nil || fn == nil {
return return
} }
@@ -170,6 +213,7 @@ func (g *Group) Group(prefix string, middleware []string, fn func(pact.Router))
pluginID: g.pluginID, pluginID: g.pluginID,
prefix: joinPath(g.prefix, prefix), prefix: joinPath(g.prefix, prefix),
middleware: append([]string{}, g.middleware...), middleware: append([]string{}, g.middleware...),
raw: raw,
} }
next.middleware = append(next.middleware, middleware...) next.middleware = append(next.middleware, middleware...)
fn(next) fn(next)
@@ -179,35 +223,35 @@ func (g *Group) Get(path string, handler http.HandlerFunc, middleware ...string)
if g == nil || g.router == nil { if g == nil || g.router == nil {
return return
} }
g.router.add(g.pluginID, g.prefix, g.middleware, http.MethodGet, path, handler, middleware) g.router.add(g.pluginID, g.prefix, g.middleware, http.MethodGet, path, handler, middleware, g.raw)
} }
func (g *Group) Post(path string, handler http.HandlerFunc, middleware ...string) { func (g *Group) Post(path string, handler http.HandlerFunc, middleware ...string) {
if g == nil || g.router == nil { if g == nil || g.router == nil {
return return
} }
g.router.add(g.pluginID, g.prefix, g.middleware, http.MethodPost, path, handler, middleware) g.router.add(g.pluginID, g.prefix, g.middleware, http.MethodPost, path, handler, middleware, g.raw)
} }
func (g *Group) Put(path string, handler http.HandlerFunc, middleware ...string) { func (g *Group) Put(path string, handler http.HandlerFunc, middleware ...string) {
if g == nil || g.router == nil { if g == nil || g.router == nil {
return return
} }
g.router.add(g.pluginID, g.prefix, g.middleware, http.MethodPut, path, handler, middleware) g.router.add(g.pluginID, g.prefix, g.middleware, http.MethodPut, path, handler, middleware, g.raw)
} }
func (g *Group) Patch(path string, handler http.HandlerFunc, middleware ...string) { func (g *Group) Patch(path string, handler http.HandlerFunc, middleware ...string) {
if g == nil || g.router == nil { if g == nil || g.router == nil {
return return
} }
g.router.add(g.pluginID, g.prefix, g.middleware, http.MethodPatch, path, handler, middleware) g.router.add(g.pluginID, g.prefix, g.middleware, http.MethodPatch, path, handler, middleware, g.raw)
} }
func (g *Group) Delete(path string, handler http.HandlerFunc, middleware ...string) { func (g *Group) Delete(path string, handler http.HandlerFunc, middleware ...string) {
if g == nil || g.router == nil { if g == nil || g.router == nil {
return return
} }
g.router.add(g.pluginID, g.prefix, g.middleware, http.MethodDelete, path, handler, middleware) g.router.add(g.pluginID, g.prefix, g.middleware, http.MethodDelete, path, handler, middleware, g.raw)
} }
// Where attaches a compiled regex constraint to the last route, matching PHP ->where(). // Where attaches a compiled regex constraint to the last route, matching PHP ->where().
@@ -266,7 +310,7 @@ func (r *Router) addConstraint(c Constraint) {
last.constraints = append(last.constraints, c) last.constraints = append(last.constraints, c)
} }
func (r *Router) add(pluginID, prefix string, groupMW []string, method, path string, handler http.HandlerFunc, extra []string) { func (r *Router) add(pluginID, prefix string, groupMW []string, method, path string, handler http.HandlerFunc, extra []string, raw bool) {
full := joinPath(prefix, path) full := joinPath(prefix, path)
key := method + " " + full key := method + " " + full
if prev, ok := r.seen[key]; ok { if prev, ok := r.seen[key]; ok {
@@ -282,6 +326,7 @@ func (r *Router) add(pluginID, prefix string, groupMW []string, method, path str
path: full, path: full,
handler: handler, handler: handler,
middleware: mw, middleware: mw,
raw: raw,
}) })
} }
@@ -297,7 +342,7 @@ func (r *Router) compile() (http.Handler, error) {
} }
mux.Handle(rt.method+" "+rt.path, h) mux.Handle(rt.method+" "+rt.path, h)
} }
return recoverJSON(cors(r.origins, mux)), nil return cors(r.origins, mux), nil
} }
func (r *Router) wrap(rt route) (http.Handler, error) { func (r *Router) wrap(rt route) (http.Handler, error) {
@@ -306,12 +351,18 @@ func (r *Router) wrap(rt route) (http.Handler, error) {
for i := len(rt.middleware) - 1; i >= 0; i-- { for i := len(rt.middleware) - 1; i >= 0; i-- {
name := rt.middleware[i] name := rt.middleware[i]
if named, ok := r.named[name]; ok { if named, ok := r.named[name]; ok {
if rt.raw && named.houseTagged {
return nil, fmt.Errorf("surf: raw group cannot use house-envelope middleware %q (plugin %q)", name, rt.pluginID)
}
h = named.fn(h) h = named.fn(h)
continue continue
} }
base, param, hasParam := strings.Cut(name, ":") base, param, hasParam := strings.Cut(name, ":")
if hasParam { if hasParam {
if factory, ok := r.factories[base]; ok { if factory, ok := r.factories[base]; ok {
if rt.raw && factory.houseTagged {
return nil, fmt.Errorf("surf: raw group cannot use house-envelope middleware %q (plugin %q)", name, rt.pluginID)
}
if base == "throttle" && r.limiter != nil { if base == "throttle" && r.limiter != nil {
if err := r.limiter.ValidateThrottle(param); err != nil { if err := r.limiter.ValidateThrottle(param); err != nil {
return nil, fmt.Errorf("surf: plugin %q: %w", rt.pluginID, err) return nil, fmt.Errorf("surf: plugin %q: %w", rt.pluginID, err)
@@ -324,11 +375,26 @@ func (r *Router) wrap(rt route) (http.Handler, error) {
return nil, fmt.Errorf("surf: plugin %q references unknown middleware %q", rt.pluginID, name) return nil, fmt.Errorf("surf: plugin %q references unknown middleware %q", rt.pluginID, name)
} }
h = locale(h) h = locale(h)
if rt.raw {
h = recoverBare(h)
} else {
h = recoverJSON(h)
}
return h, nil return h, nil
} }
// Assemble registers plugin middleware and routes, then compiles ServeMux. // Assemble registers plugin middleware and routes, then compiles ServeMux.
func Assemble(app *backpack.App, plugins []party.Plugin) (http.Handler, error) { func Assemble(app *backpack.App, plugins []party.Plugin) (http.Handler, error) {
r, err := BuildRouter(app, plugins)
if err != nil {
return nil, err
}
return r.compile()
}
// BuildRouter registers plugin middleware and routes without compiling ServeMux,
// so callers (route:list) can inspect Routes() after a successful boot.
func BuildRouter(app *backpack.App, plugins []party.Plugin) (*Router, error) {
r := New(corsOrigins(app)) r := New(corsOrigins(app))
trusted := TrustedProxies(nil) trusted := TrustedProxies(nil)
if app != nil { if app != nil {
@@ -360,6 +426,15 @@ func Assemble(app *backpack.App, plugins []party.Plugin) (http.Handler, error) {
} }
} }
} }
for _, p := range plugins {
if hh, ok := p.(pact.HasHouseMiddleware); ok {
for name, fn := range hh.HouseMiddlewares() {
if err := r.RegisterHouseMiddleware(p.ID(), name, fn); err != nil {
return nil, err
}
}
}
}
for _, p := range plugins { for _, p := range plugins {
if bp, ok := p.(BucketProvider); ok { if bp, ok := p.(BucketProvider); ok {
for name, b := range bp.Buckets() { for name, b := range bp.Buckets() {
@@ -382,7 +457,7 @@ func Assemble(app *backpack.App, plugins []party.Plugin) (http.Handler, error) {
return nil, err return nil, err
} }
} }
return r.compile() return r, nil
} }
func corsOrigins(app *backpack.App) []string { func corsOrigins(app *backpack.App) []string {
@@ -423,6 +498,17 @@ func recoverJSON(next http.Handler) http.Handler {
}) })
} }
func recoverBare(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
w.WriteHeader(http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
func cors(origins []string, next http.Handler) http.Handler { func cors(origins []string, next http.Handler) http.Handler {
allowed := make(map[string]struct{}, len(origins)) allowed := make(map[string]struct{}, len(origins))
for _, o := range origins { for _, o := range origins {

View File

@@ -7,7 +7,9 @@ import (
"strings" "strings"
"testing" "testing"
"git.golem15.com/golem15/summercms/backpack"
"git.golem15.com/golem15/summercms/pact" "git.golem15.com/golem15/summercms/pact"
"git.golem15.com/golem15/summercms/party"
) )
type routePlugin struct { type routePlugin struct {
@@ -46,6 +48,72 @@ func TestMissingMiddlewareNamesPluginAndName(t *testing.T) {
} }
} }
func TestRawGroupPanicBare500(t *testing.T) {
r := New(nil)
r.GroupRaw("/oauth", nil, func(g pact.Router) {
g.Get("/panic", func(http.ResponseWriter, *http.Request) {
panic("secret internals")
})
})
r.Get("/panic", func(http.ResponseWriter, *http.Request) {
panic("secret internals")
})
h, err := r.compile()
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/oauth/panic", nil))
if rec.Code != http.StatusInternalServerError {
t.Fatalf("raw status = %d", rec.Code)
}
if rec.Body.Len() != 0 {
t.Fatalf("raw body = %q", rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); ct != "" {
t.Fatalf("raw Content-Type = %q", ct)
}
rec = httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/panic", nil))
if rec.Code != http.StatusInternalServerError {
t.Fatalf("house status = %d", rec.Code)
}
if rec.Header().Get("Content-Type") != "application/json" {
t.Fatalf("house Content-Type = %q", rec.Header().Get("Content-Type"))
}
var payload map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["error"] != true || payload["message"] != "Internal server error" {
t.Fatalf("payload = %v", payload)
}
}
func TestHouseMiddlewareDuplicateNameFailsBoot(t *testing.T) {
identity := func(next http.Handler) http.Handler { return next }
named := assemblePlugin{
id: "golem15.one",
mw: map[string]pact.Middleware{"shared.mw": identity},
use: []string{},
}
house := houseRoutePlugin{
id: "golem15.two",
house: map[string]pact.Middleware{
"shared.mw": identity,
},
}
_, err := BuildRouter(backpack.New(nil), []party.Plugin{named, house})
if err == nil {
t.Fatal("want duplicate-name error")
}
if !strings.Contains(err.Error(), "shared.mw") || !strings.Contains(err.Error(), "golem15.one") {
t.Fatalf("want existing duplicate-name error, got %v", err)
}
}
func TestRecoverReturnsOpaqueJSON500(t *testing.T) { func TestRecoverReturnsOpaqueJSON500(t *testing.T) {
r := New(nil) r := New(nil)
r.Get("/panic", func(http.ResponseWriter, *http.Request) { r.Get("/panic", func(http.ResponseWriter, *http.Request) {

30
surf/routetable.go Normal file
View File

@@ -0,0 +1,30 @@
package surf
// RouteInfo is a read-only snapshot of one registered route after Assemble
// or BuildRouter.
type RouteInfo struct {
Method string
Pattern string
PluginID string
Middleware []string
Raw bool
}
// Routes returns a defensive copy of every registered route.
func (r *Router) Routes() []RouteInfo {
if r == nil {
return nil
}
out := make([]RouteInfo, len(r.routes))
for i, rt := range r.routes {
mw := append([]string{}, rt.middleware...)
out[i] = RouteInfo{
Method: rt.method,
Pattern: rt.path,
PluginID: rt.pluginID,
Middleware: mw,
Raw: rt.raw,
}
}
return out
}

126
surf/routetable_test.go Normal file
View File

@@ -0,0 +1,126 @@
package surf
import (
"net/http"
"strings"
"testing"
"git.golem15.com/golem15/summercms/backpack"
"git.golem15.com/golem15/summercms/pact"
"git.golem15.com/golem15/summercms/party"
)
func TestRouteTableRawFlagAndStickyInheritance(t *testing.T) {
r := New(nil)
r.BindPlugin("golem15.demo")
r.Get("/plain", func(http.ResponseWriter, *http.Request) {})
r.Group("/g", nil, func(g pact.Router) {
g.Get("/nested", func(http.ResponseWriter, *http.Request) {})
})
r.GroupRaw("/raw", nil, func(g pact.Router) {
g.Get("/a", func(http.ResponseWriter, *http.Request) {})
g.Group("/child", nil, func(c pact.Router) {
c.Get("/b", func(http.ResponseWriter, *http.Request) {})
})
})
byPattern := map[string]RouteInfo{}
for _, rt := range r.Routes() {
byPattern[rt.Pattern] = rt
}
want := map[string]bool{
"/plain": false,
"/g/nested": false,
"/raw/a": true,
"/raw/child/b": true,
}
for pattern, raw := range want {
got, ok := byPattern[pattern]
if !ok {
t.Fatalf("missing route %s in %v", pattern, keys(byPattern))
}
if got.Raw != raw {
t.Fatalf("%s Raw = %v, want %v", pattern, got.Raw, raw)
}
if got.Method != http.MethodGet {
t.Fatalf("%s Method = %s", pattern, got.Method)
}
if got.PluginID != "golem15.demo" {
t.Fatalf("%s PluginID = %s", pattern, got.PluginID)
}
}
}
func keys(m map[string]RouteInfo) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
func TestRouteTableDoesNotAliasInternalSlice(t *testing.T) {
r := New(nil)
r.BindPlugin("golem15.demo")
r.Group("/api", Use("jwt.auth"), func(g pact.Router) {
g.Get("/items", func(http.ResponseWriter, *http.Request) {})
})
first := r.Routes()
if len(first) != 1 || len(first[0].Middleware) != 1 {
t.Fatalf("got %+v", first)
}
first[0].Middleware[0] = "mutated"
first[0].Pattern = "/changed"
second := r.Routes()
if second[0].Middleware[0] != "jwt.auth" {
t.Fatalf("internal middleware aliased: %v", second[0].Middleware)
}
if second[0].Pattern != "/api/items" {
t.Fatalf("internal pattern aliased: %s", second[0].Pattern)
}
}
func TestRawGroupHouseMiddlewareRefusedAtBuild(t *testing.T) {
identity := func(next http.Handler) http.Handler { return next }
p := houseRoutePlugin{
id: "golem15.demo",
house: map[string]pact.Middleware{
"house.err": identity,
},
rawUse: []string{"house.err"},
}
_, err := BuildRouter(backpack.New(nil), []party.Plugin{p})
if err == nil {
t.Fatal("want BuildRouter error")
}
if !strings.Contains(err.Error(), "house.err") {
t.Fatalf("want middleware name in error, got %v", err)
}
if !strings.Contains(err.Error(), "golem15.demo") {
t.Fatalf("want plugin in error, got %v", err)
}
if !strings.Contains(err.Error(), "raw group cannot use house-envelope middleware") {
t.Fatalf("want raw-group refusal, got %v", err)
}
}
type houseRoutePlugin struct {
id string
house map[string]pact.Middleware
mw map[string]pact.Middleware
rawUse []string
}
func (p houseRoutePlugin) ID() string { return p.id }
func (p houseRoutePlugin) Requires() []string { return nil }
func (p houseRoutePlugin) Register(*backpack.App) error { return nil }
func (p houseRoutePlugin) Boot(*backpack.App) error { return nil }
func (p houseRoutePlugin) Middlewares() map[string]pact.Middleware { return p.mw }
func (p houseRoutePlugin) HouseMiddlewares() map[string]pact.Middleware { return p.house }
func (p houseRoutePlugin) Routes(r pact.Router) error {
r.GroupRaw("/oauth", Use(p.rawUse...), func(g pact.Router) {
g.Get("/x", func(http.ResponseWriter, *http.Request) {})
})
return nil
}