gw-03 step 01 — The filter chain and request context
Goal
Build the Zuul-shaped core: a RequestContext threaded through three
ordered phases (inbound → endpoint → outbound), with short-circuit and
guaranteed outbound execution. Everything else in this lab plugs into
this.
Code — src/go/gateway.go
package gw
import (
"net/http"
"sort"
"time"
)
type Phase int
const (
Inbound Phase = iota
Endpoint
Outbound
)
// RequestContext is the mutable state carried through the chain
// (Zuul's SessionContext).
type RequestContext struct {
Req *http.Request
Resp *ResponseBuilder
RouteName string // set by a routing filter (step 02)
Attributes map[string]any // cross-filter data (e.g. identity)
Timings map[string]time.Duration
stopped bool // short-circuit flag
startedAt time.Time
}
func (c *RequestContext) Stop() { c.stopped = true }
func (c *RequestContext) Stopped() bool { return c.stopped }
// ResponseBuilder is the response being assembled.
type ResponseBuilder struct {
Status int
Header http.Header
Body []byte
}
// Filter is the unit of gateway logic (Zuul's ZuulFilter).
type Filter interface {
Type() Phase
Order() int // lower runs first within a phase
ShouldFilter(c *RequestContext) bool
Apply(c *RequestContext)
}
type Gateway struct {
inbound []Filter
endpoint Filter // exactly one terminal action
outbound []Filter
}
func (g *Gateway) Use(f Filter) {
switch f.Type() {
case Inbound:
g.inbound = append(g.inbound, f)
sort.SliceStable(g.inbound, func(i, j int) bool {
return g.inbound[i].Order() < g.inbound[j].Order()
})
case Endpoint:
g.endpoint = f
case Outbound:
g.outbound = append(g.outbound, f)
sort.SliceStable(g.outbound, func(i, j int) bool {
return g.outbound[i].Order() < g.outbound[j].Order()
})
}
}
// ServeHTTP runs the full lifecycle for one request.
func (g *Gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) {
c := &RequestContext{
Req: r,
Resp: &ResponseBuilder{Status: 0, Header: http.Header{}},
Attributes: map[string]any{},
Timings: map[string]time.Duration{},
startedAt: time.Now(),
}
// INBOUND: stop as soon as a filter short-circuits.
for _, f := range g.inbound {
if c.Stopped() {
break
}
g.runOne(c, f)
}
// ENDPOINT: only if not already short-circuited (e.g. by auth/cache).
if !c.Stopped() && g.endpoint != nil && g.endpoint.ShouldFilter(c) {
g.runOne(c, g.endpoint)
}
// OUTBOUND: ALWAYS runs — access logs/metrics must fire even on 401/429.
for _, f := range g.outbound {
g.runOne(c, f)
}
g.write(w, c)
}
func (g *Gateway) runOne(c *RequestContext, f Filter) {
if !f.ShouldFilter(c) {
return
}
start := time.Now()
defer func() {
// An error filter (step 03) turns panics into 5xx; the chain
// must never crash the connection on a filter bug.
if rec := recover(); rec != nil {
c.Resp.Status = http.StatusBadGateway
c.Resp.Body = []byte("filter error")
}
c.Timings[name(f)] = time.Since(start)
}()
f.Apply(c)
}
func (g *Gateway) write(w http.ResponseWriter, c *RequestContext) {
if c.Resp.Status == 0 {
c.Resp.Status = http.StatusOK
}
for k, vs := range c.Resp.Header {
for _, v := range vs {
w.Header().Add(k, v)
}
}
w.WriteHeader(c.Resp.Status)
w.Write(c.Resp.Body)
}
Code — three example filters
package gw
import (
"fmt"
"net/http"
)
// AuthFilter (inbound, early): short-circuits a missing/invalid token.
type AuthFilter struct{}
func (AuthFilter) Type() Phase { return Inbound }
func (AuthFilter) Order() int { return 10 }
func (AuthFilter) ShouldFilter(c *RequestContext) bool { return true }
func (AuthFilter) Apply(c *RequestContext) {
tok := c.Req.Header.Get("Authorization")
if tok == "" {
c.Resp.Status = http.StatusUnauthorized
c.Resp.Body = []byte("missing token")
c.Stop() // no routing, no origin call for a 401
return
}
c.Attributes["identity"] = "user-from:" + tok // gw-07 does this properly
}
// HealthEndpoint (endpoint): serve /healthz locally, no origin call.
type HealthEndpoint struct{}
func (HealthEndpoint) Type() Phase { return Endpoint }
func (HealthEndpoint) Order() int { return 0 }
func (HealthEndpoint) ShouldFilter(c *RequestContext) bool {
return c.Req.URL.Path == "/healthz"
}
func (HealthEndpoint) Apply(c *RequestContext) {
c.Resp.Status = http.StatusOK
c.Resp.Body = []byte("ok")
}
// AccessLog (outbound, last): always runs, even on short-circuit.
type AccessLog struct{}
func (AccessLog) Type() Phase { return Outbound }
func (AccessLog) Order() int { return 1000 }
func (AccessLog) ShouldFilter(c *RequestContext) bool { return true }
func (AccessLog) Apply(c *RequestContext) {
fmt.Printf("%s %s -> %d route=%q timings=%v\n",
c.Req.Method, c.Req.URL.Path, c.Resp.Status, c.RouteName, c.Timings)
}
Tasks
- Implement
Gatewayand the three filters; wire withhttp.ListenAndServe(":8080", g). - Confirm phase ordering:
curl :8080/healthz(no auth) returns 401 — because auth (inbound, order 10) runs before the endpoint. Add an exemption so/healthzskips auth, and confirm the access log fires on both the 401 and the 200. - Add a second inbound filter at order 50 that sets a header; prove it runs after auth and is skipped when auth short-circuits.
Acceptance
- Inbound filters run in
Order(); auth short-circuits before routing. - Outbound
AccessLogfires on every request, including short-circuited ones — verify the 401 is logged. - A panicking filter yields a 502, not a dropped connection.
Discussion prompts
- Why must outbound filters run even when an inbound filter short-circuits? (Metrics/logs/trace completion.)
- Why is there exactly one endpoint filter but many inbound/outbound?
- Where would you put a rate limiter, and why before routing? (Don't spend an origin connection on a request you'll 429.)