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

  1. Implement Gateway and the three filters; wire with http.ListenAndServe(":8080", g).
  2. Confirm phase ordering: curl :8080/healthz (no auth) returns 401 — because auth (inbound, order 10) runs before the endpoint. Add an exemption so /healthz skips auth, and confirm the access log fires on both the 401 and the 200.
  3. 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 AccessLog fires 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.)