feat(19.7-01): in-process dispatch marker and compiled handler lookup
- bouncer.Dispatch, WithDispatch, Dispatched: context-only pre-resolved
principal marker honoured by Registry.Middleware for listed guard names
- cabana.ResolveBackend honours a backend-kind marker; additive
ResolveBackendToken, ScopeLimitFor and BackendPrincipal resolvers
- surf.Router.Handler and compileHandlers: one compiled chain per route,
shared by BuildRouter validation and compile
- smoke tests TestDispatchSmoke, TestDispatchedSmoke, TestDispatchMarkerSmoke
- READMEs and docs/services/{authentication,routing}.md updated
This commit is contained in:
@@ -126,6 +126,7 @@ The generated application `main` wires surf in with `surf.ServeCommand` and `sur
|
||||
| `surf.Router.RegisterMiddleware` | Stores a named middleware; duplicates fail. |
|
||||
| `surf.Router.RegisterMiddlewareFactory` | Stores a parameterized middleware used as `name:param`. |
|
||||
| `surf.Router.Routes` | Returns a copy of the registered routes as `surf.RouteInfo` values. |
|
||||
| `surf.Router.Handler` | Returns the compiled middleware chain of one registered route by method and pattern, for an in-process dispatch that must run the same guards, throttles, body limit, locale and recovery as an HTTP request. False for an unknown route or before `surf.BuildRouter` compiled the routes. |
|
||||
| `surf.Router.AdminPrefix` | The admin mount path and true when `surf.BuildRouter` mounted the cabana admin; false when no plugin registers an admin controller. |
|
||||
| `surf.RouteInfo` | Method, pattern, owning plugin, middleware and raw flag of one route. |
|
||||
| `surf.Use` | Builds a middleware name list for a group. |
|
||||
|
||||
142
modules/surf/dispatch_smoke_test.go
Normal file
142
modules/surf/dispatch_smoke_test.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package surf
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
)
|
||||
|
||||
// Smoke test for the in-process dispatch tracer of phase 19.7 (D-17): a
|
||||
// request carrying the bouncer dispatch marker runs a guarded route through
|
||||
// surf's compiled chain as the pre-resolved principal, while nothing an HTTP
|
||||
// request can carry is treated as one.
|
||||
|
||||
// refusingGuard never authenticates a request and writes its own 401.
|
||||
type refusingGuard struct{}
|
||||
|
||||
func (refusingGuard) AuthenticateCredential(*http.Request) (*bouncer.Principal, any, error) {
|
||||
return nil, nil, errors.New("acme: no token")
|
||||
}
|
||||
|
||||
func (refusingGuard) WriteUnauthorized(w http.ResponseWriter, err error) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"error":true,"message":"Unauthenticated"}`))
|
||||
}
|
||||
|
||||
// dispatchPlugin registers bouncer guard "acme" as middleware "acme.auth"
|
||||
// and one guarded route.
|
||||
type dispatchPlugin struct{}
|
||||
|
||||
func (dispatchPlugin) ID() string { return "acme.items" }
|
||||
func (dispatchPlugin) Requires() []string { return nil }
|
||||
func (dispatchPlugin) Register(*backpack.App) error { return nil }
|
||||
func (dispatchPlugin) Boot(*backpack.App) error { return nil }
|
||||
|
||||
func (p dispatchPlugin) Middlewares() map[string]pact.Middleware {
|
||||
guards := bouncer.NewRegistry()
|
||||
if err := guards.Register(p.ID(), "acme", refusingGuard{}); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
auth, err := guards.Middleware("acme")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return map[string]pact.Middleware{"acme.auth": auth}
|
||||
}
|
||||
|
||||
func (dispatchPlugin) Routes(r pact.Router) error {
|
||||
r.Group("/acme", Use("acme.auth"), func(g pact.Router) {
|
||||
g.Get("/items/{id}", func(w http.ResponseWriter, req *http.Request) {
|
||||
user, _ := bouncer.User(req.Context())
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"user":` + itoa(user.ID) + `,"item":"` + req.PathValue("id") + `"}`))
|
||||
})
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func itoa(n uint) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
var b []byte
|
||||
for n > 0 {
|
||||
b = append([]byte{byte('0' + n%10)}, b...)
|
||||
n /= 10
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TestDispatchSmoke(t *testing.T) {
|
||||
app := transportApp(t, "development", map[string]string{"http.yaml": transportBodyLimits})
|
||||
router, err := BuildRouter(app, []party.Plugin{dispatchPlugin{}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := router.Handler("GET", "/acme/items/{id}/missing"); ok {
|
||||
t.Fatal("Handler found an unregistered route")
|
||||
}
|
||||
h, ok := router.Handler("GET", "/acme/items/{id}")
|
||||
if !ok || h == nil {
|
||||
t.Fatal("Handler did not find GET /acme/items/{id} after BuildRouter")
|
||||
}
|
||||
var nilRouter *Router
|
||||
if _, ok := nilRouter.Handler("GET", "/acme/items/{id}"); ok {
|
||||
t.Fatal("nil router found a handler")
|
||||
}
|
||||
|
||||
serve := func(set func(*http.Request) *http.Request) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, "/acme/items/7", nil)
|
||||
req.SetPathValue("id", "7")
|
||||
req.Pattern = "GET /acme/items/{id}"
|
||||
req = set(req)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
marker := func(guards ...string) func(*http.Request) *http.Request {
|
||||
return func(req *http.Request) *http.Request {
|
||||
d := &bouncer.Dispatch{Guards: guards, Principal: &bouncer.Principal{ID: 42}}
|
||||
return req.WithContext(bouncer.WithDispatch(req.Context(), d))
|
||||
}
|
||||
}
|
||||
|
||||
rec := serve(marker("acme"))
|
||||
if rec.Code != http.StatusOK || strings.TrimSpace(rec.Body.String()) != `{"user":42,"item":"7"}` {
|
||||
t.Fatalf("dispatch with the marker = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := serve(func(req *http.Request) *http.Request { return req }); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("request without the marker = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
forged := serve(func(req *http.Request) *http.Request {
|
||||
req.Header.Set("X-Summer-Dispatch", "42")
|
||||
req.Header.Set("Authorization", "Dispatch 42")
|
||||
req.AddCookie(&http.Cookie{Name: "summer_dispatch", Value: "42"})
|
||||
return req
|
||||
})
|
||||
if forged.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("forged headers = %d %s", forged.Code, forged.Body.String())
|
||||
}
|
||||
if rec := serve(marker("other")); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("marker for another guard = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// The assembled handler serves the same route over HTTP unchanged.
|
||||
full, err := router.compile()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
full.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/acme/items/7", nil))
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("HTTP request without a token = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,9 @@ type Router struct {
|
||||
defaultBytes int64
|
||||
uploadBytes int64
|
||||
built map[string]pact.Middleware
|
||||
// handlers holds the compiled middleware chain of every route, keyed
|
||||
// "METHOD /pattern", filled by compileHandlers; Handler reads it.
|
||||
handlers map[string]http.Handler
|
||||
app *backpack.App
|
||||
adminPrefix string
|
||||
adminMounted bool
|
||||
@@ -351,17 +354,42 @@ func (r *Router) add(pluginID, prefix string, groupMW []string, method, path str
|
||||
})
|
||||
}
|
||||
|
||||
// compileHandlers wraps every registered route that has no compiled chain
|
||||
// yet (constraints, named middleware, body limit, locale, recovery, CORS
|
||||
// profile and the access-log pattern) and records it under "METHOD /pattern".
|
||||
// BuildRouter calls it to validate the routes, compile reuses the chains and
|
||||
// Handler hands one out for an in-process dispatch.
|
||||
func (r *Router) compileHandlers() error {
|
||||
if r == nil {
|
||||
return fmt.Errorf("surf: router is nil")
|
||||
}
|
||||
if r.handlers == nil {
|
||||
r.handlers = make(map[string]http.Handler)
|
||||
}
|
||||
for _, rt := range r.routes {
|
||||
key := rt.method + " " + rt.path
|
||||
if _, done := r.handlers[key]; done {
|
||||
continue
|
||||
}
|
||||
h, err := r.wrap(rt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.handlers[key] = h
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Router) compile() (http.Handler, error) {
|
||||
if r.compileErr != nil {
|
||||
return nil, r.compileErr
|
||||
}
|
||||
if err := r.compileHandlers(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
handlers := make([]http.Handler, len(r.routes))
|
||||
for i, rt := range r.routes {
|
||||
h, err := r.wrap(rt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
handlers[i] = h
|
||||
handlers[i] = r.handlers[rt.method+" "+rt.path]
|
||||
}
|
||||
// Routes that ServeMux refuses side by side but Where constraints keep
|
||||
// apart register as overlap families (overlap.go); the rest as before.
|
||||
@@ -652,10 +680,8 @@ func BuildRouter(app *backpack.App, plugins []party.Plugin) (*Router, error) {
|
||||
r.pages.setAdminPrefix(admin.Prefix)
|
||||
r.adminMounted = true
|
||||
}
|
||||
for _, rt := range r.routes {
|
||||
if _, err := r.wrap(rt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.compileHandlers(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
warnTurnstileWithoutSecret(app, turnstile, r.routes)
|
||||
if err := r.addCORSPreflights(); err != nil {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package surf
|
||||
|
||||
import "net/http"
|
||||
|
||||
// RouteInfo is a read-only snapshot of one registered route after Assemble
|
||||
// or BuildRouter.
|
||||
type RouteInfo struct {
|
||||
@@ -37,3 +39,17 @@ func (r *Router) Routes() []RouteInfo {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Handler returns the compiled middleware chain of the route registered for
|
||||
// method and pattern (as listed by Routes), for an in-process dispatch that
|
||||
// must run the same constraints, guards, throttles, body limit, locale and
|
||||
// recovery as an HTTP request. The caller sets the path values and Pattern
|
||||
// on its request before serving, as the mux would. False when no such route
|
||||
// exists or BuildRouter has not compiled the routes yet.
|
||||
func (r *Router) Handler(method, pattern string) (http.Handler, bool) {
|
||||
if r == nil || r.handlers == nil {
|
||||
return nil, false
|
||||
}
|
||||
h, ok := r.handlers[method+" "+pattern]
|
||||
return h, ok && h != nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user