feat(quickproxy): support per-rule Exclude path prefixes

Rule.Exclude lists path prefixes that should never be proxied by that
rule, even though they fall under its URLPrefix. A request matching an
Exclude prefix is treated as a non-match for that rule: matching
continues against other configured rules, falling back to the
caller-supplied handler if none apply. Lets a catch-all "/" rule proxy
everything except carved-out paths like "/health".
This commit is contained in:
Hein
2026-09-17 11:38:02 +02:00
parent 6de9be0ae7
commit 0d8b136b91
2 changed files with 120 additions and 6 deletions
+35 -2
View File
@@ -26,6 +26,15 @@ type Rule struct {
// The incoming request path and query are forwarded unchanged; only the // The incoming request path and query are forwarded unchanged; only the
// scheme and host are rewritten to Target's. // scheme and host are rewritten to Target's.
Target string Target string
// Exclude is a list of URL path prefixes that this rule should not
// proxy, even though they fall under URLPrefix. Each entry must start
// with "/". A request matching an Exclude prefix is treated as if this
// rule didn't match at all: matching continues against any other
// configured rule, falling back if none match. This is typically used
// to carve out paths (e.g. "/health") from a catch-all "/" rule so
// they're served by the fallback handler instead of being proxied.
Exclude []string
} }
// DefaultTimeout is the dial and response-header timeout applied to // DefaultTimeout is the dial and response-header timeout applied to
@@ -51,9 +60,20 @@ func WithTimeout(d time.Duration) Option {
// compiledRule pairs a Rule with its ready-to-use reverse proxy. // compiledRule pairs a Rule with its ready-to-use reverse proxy.
type compiledRule struct { type compiledRule struct {
prefix string prefix string
excludes []string
proxy *httputil.ReverseProxy proxy *httputil.ReverseProxy
} }
// excluded reports whether path falls under one of the rule's Exclude prefixes.
func (r *compiledRule) excluded(path string) bool {
for _, ex := range r.excludes {
if strings.HasPrefix(path, ex) {
return true
}
}
return false
}
// Service holds a compiled set of proxy rules and performs longest-prefix // Service holds a compiled set of proxy rules and performs longest-prefix
// matching against them. A Service is safe for concurrent use once // matching against them. A Service is safe for concurrent use once
// returned from NewService; Handler must be called once per Service to // returned from NewService; Handler must be called once per Service to
@@ -98,8 +118,15 @@ func NewService(rules []Rule, opts ...Option) (*Service, error) {
return nil, fmt.Errorf("quickproxy: invalid target %q for prefix %q", r.Target, r.URLPrefix) return nil, fmt.Errorf("quickproxy: invalid target %q for prefix %q", r.Target, r.URLPrefix)
} }
for _, ex := range r.Exclude {
if !strings.HasPrefix(ex, "/") {
return nil, fmt.Errorf("quickproxy: exclude prefix %q for rule %q must start with /", ex, r.URLPrefix)
}
}
compiled = append(compiled, compiledRule{ compiled = append(compiled, compiledRule{
prefix: r.URLPrefix, prefix: r.URLPrefix,
excludes: r.Exclude,
proxy: newReverseProxy(target, cfg.timeout), proxy: newReverseProxy(target, cfg.timeout),
}) })
} }
@@ -174,11 +201,17 @@ func (s *Service) Handler(fallback http.Handler) http.Handler {
} }
// match returns the longest-prefix rule matching path, or nil if none match. // match returns the longest-prefix rule matching path, or nil if none match.
// A rule whose Exclude covers path is skipped, and matching continues
// against the next-longest-prefix rule.
func (s *Service) match(path string) *compiledRule { func (s *Service) match(path string) *compiledRule {
for i := range s.rules { for i := range s.rules {
if strings.HasPrefix(path, s.rules[i].prefix) { if !strings.HasPrefix(path, s.rules[i].prefix) {
return &s.rules[i] continue
} }
if s.rules[i].excluded(path) {
continue
}
return &s.rules[i]
} }
return nil return nil
} }
+81
View File
@@ -23,7 +23,13 @@ func TestNewService_Validation(t *testing.T) {
{URLPrefix: "/api", Target: "http://localhost:1"}, {URLPrefix: "/api", Target: "http://localhost:1"},
{URLPrefix: "/api", Target: "http://localhost:2"}, {URLPrefix: "/api", Target: "http://localhost:2"},
}, true}, }, true},
{"bad exclude prefix", []Rule{
{URLPrefix: "/", Target: "http://localhost:1", Exclude: []string{"health"}},
}, true},
{"valid", []Rule{{URLPrefix: "/api", Target: "http://localhost:1"}}, false}, {"valid", []Rule{{URLPrefix: "/api", Target: "http://localhost:1"}}, false},
{"valid with exclude", []Rule{
{URLPrefix: "/", Target: "http://localhost:1", Exclude: []string{"/health"}},
}, false},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -186,6 +192,81 @@ func TestHandler_LongestPrefixMatch(t *testing.T) {
} }
} }
func TestHandler_ExcludeFallsBackToFallback(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("upstream:" + r.URL.Path))
}))
defer upstream.Close()
svc, err := NewService([]Rule{
{URLPrefix: "/", Target: upstream.URL, Exclude: []string{"/health"}},
})
if err != nil {
t.Fatalf("NewService: %v", err)
}
handler := svc.Handler(fallbackHandler("fallback-content"))
req := httptest.NewRequest(http.MethodGet, "/health", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if got := rr.Body.String(); got != "fallback-content" {
t.Fatalf("body = %q, want fallback-content", got)
}
req = httptest.NewRequest(http.MethodGet, "/health/live", nil)
rr = httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if got := rr.Body.String(); got != "fallback-content" {
t.Fatalf("body = %q, want fallback-content", got)
}
req = httptest.NewRequest(http.MethodGet, "/other", nil)
rr = httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if got := rr.Body.String(); got != "upstream:/other" {
t.Fatalf("body = %q, want upstream:/other", got)
}
}
func TestHandler_ExcludeFallsThroughToNextRule(t *testing.T) {
specific := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("specific"))
}))
defer specific.Close()
general := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("general"))
}))
defer general.Close()
svc, err := NewService([]Rule{
{URLPrefix: "/api", Target: general.URL},
{URLPrefix: "/api/v1", Target: specific.URL, Exclude: []string{"/api/v1/health"}},
})
if err != nil {
t.Fatalf("NewService: %v", err)
}
handler := svc.Handler(fallbackHandler("fallback"))
for path, want := range map[string]string{
"/api/v1/thing": "specific",
"/api/v1/health": "general",
} {
req := httptest.NewRequest(http.MethodGet, path, nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if got := rr.Body.String(); got != want {
t.Errorf("path %s: body = %q, want %q", path, got, want)
}
}
}
func TestHandler_NoMatchFallsBack(t *testing.T) { func TestHandler_NoMatchFallsBack(t *testing.T) {
svc, err := NewService([]Rule{{URLPrefix: "/api", Target: "http://127.0.0.1:1"}}) svc, err := NewService([]Rule{{URLPrefix: "/api", Target: "http://127.0.0.1:1"}})
if err != nil { if err != nil {