From 0d8b136b910b5dac229e84b4558678146d812c15 Mon Sep 17 00:00:00 2001 From: Hein Date: Thu, 17 Sep 2026 11:38:02 +0200 Subject: [PATCH] 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". --- pkg/server/quickproxy/quickproxy.go | 45 +++++++++++-- pkg/server/quickproxy/quickproxy_test.go | 81 ++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 6 deletions(-) diff --git a/pkg/server/quickproxy/quickproxy.go b/pkg/server/quickproxy/quickproxy.go index 0d6e00a..7593a03 100644 --- a/pkg/server/quickproxy/quickproxy.go +++ b/pkg/server/quickproxy/quickproxy.go @@ -26,6 +26,15 @@ type Rule struct { // The incoming request path and query are forwarded unchanged; only the // scheme and host are rewritten to Target's. 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 @@ -50,8 +59,19 @@ func WithTimeout(d time.Duration) Option { // compiledRule pairs a Rule with its ready-to-use reverse proxy. type compiledRule struct { - prefix string - proxy *httputil.ReverseProxy + prefix string + excludes []string + 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 @@ -98,9 +118,16 @@ func NewService(rules []Rule, opts ...Option) (*Service, error) { 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{ - prefix: r.URLPrefix, - proxy: newReverseProxy(target, cfg.timeout), + prefix: r.URLPrefix, + excludes: r.Exclude, + 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. +// A rule whose Exclude covers path is skipped, and matching continues +// against the next-longest-prefix rule. func (s *Service) match(path string) *compiledRule { for i := range s.rules { - if strings.HasPrefix(path, s.rules[i].prefix) { - return &s.rules[i] + if !strings.HasPrefix(path, s.rules[i].prefix) { + continue } + if s.rules[i].excluded(path) { + continue + } + return &s.rules[i] } return nil } diff --git a/pkg/server/quickproxy/quickproxy_test.go b/pkg/server/quickproxy/quickproxy_test.go index 30d0909..fc329de 100644 --- a/pkg/server/quickproxy/quickproxy_test.go +++ b/pkg/server/quickproxy/quickproxy_test.go @@ -23,7 +23,13 @@ func TestNewService_Validation(t *testing.T) { {URLPrefix: "/api", Target: "http://localhost:1"}, {URLPrefix: "/api", Target: "http://localhost:2"}, }, true}, + {"bad exclude prefix", []Rule{ + {URLPrefix: "/", Target: "http://localhost:1", Exclude: []string{"health"}}, + }, true}, {"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 { @@ -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) { svc, err := NewService([]Rule{{URLPrefix: "/api", Target: "http://127.0.0.1:1"}}) if err != nil {