Skip to content

Commit 37d4b9e

Browse files
committed
feat: add configurable HTTP header policies via new headers middleware
- Introduces a new `headers` configuration section where admins can define arbitrary HTTP headers per endpoint using regex path patterns. - Policies are evaluated in order; the first match wins, allowing fine-grained control over Cache-Control, Vary, security and custom headers. - Removes hard-coded Cache-Control headers from API config handler, proxy responses and frontend static serving so they are now driven by policy. - Adds comprehensive tests for policy loading, matching logic, middleware integration and concurrent usage.
1 parent 3ceab0a commit 37d4b9e

File tree

11 files changed

+1077
-13
lines changed

11 files changed

+1077
-13
lines changed

config.example.yaml

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,3 +133,59 @@ features:
133133

134134
# Example: Attestation performance - enabled for all networks (disabled_networks omitted)
135135
- path: "/ethereum/attestation-performance"
136+
137+
# HTTP Headers Configuration
138+
# Allows setting arbitrary HTTP headers per endpoint based on path patterns
139+
# Policies are evaluated in order - first match wins
140+
# Use this for Cache-Control, Vary, security headers, custom headers, etc.
141+
headers:
142+
policies:
143+
# Static assets (JS, CSS, images, fonts) - aggressive caching
144+
# Pattern matches file extensions for immutable assets with content hashing
145+
- name: "static_assets"
146+
path_pattern: "\\.(js|css|png|jpg|jpeg|gif|svg|woff|woff2|ttf|eot|ico)$"
147+
headers:
148+
Cache-Control: "public, max-age=31536000, immutable"
149+
# Vary: "Accept-Encoding" # Example: vary cache on encoding
150+
151+
# HTML pages (index.html) - minimal caching with stale-while-revalidate
152+
# Pattern ensures dynamic content gets refreshed quickly
153+
- name: "html_pages"
154+
path_pattern: "\\.html$"
155+
headers:
156+
Cache-Control: "public, max-age=1, s-maxage=5, stale-while-revalidate=1"
157+
158+
# API config endpoint - moderate CDN caching with long stale window
159+
# Config changes infrequently so longer stale serving is acceptable
160+
- name: "api_config"
161+
path_pattern: "^/api/v1/config$"
162+
headers:
163+
Cache-Control: "public, max-age=1, s-maxage=60, stale-while-revalidate=300"
164+
165+
# API proxy responses - short caching with stale-while-revalidate
166+
# Proxied API responses should be relatively fresh
167+
- name: "api_proxy"
168+
path_pattern: "^/api/v1/.+/.+"
169+
headers:
170+
Cache-Control: "public, max-age=1, s-maxage=5, stale-while-revalidate=1"
171+
172+
# Health and metrics - no caching
173+
# Monitoring endpoints must always be fresh
174+
- name: "health_metrics"
175+
path_pattern: "^/(health|metrics)$"
176+
headers:
177+
Cache-Control: "no-cache, no-store, must-revalidate"
178+
179+
# Example: Custom headers for specific endpoint
180+
# - name: "custom_endpoint"
181+
# path_pattern: "^/api/v1/special$"
182+
# headers:
183+
# Cache-Control: "public, max-age=300"
184+
# X-Custom-Header: "my-value"
185+
# Vary: "Accept-Encoding, Accept-Language"
186+
187+
# Default fallback - minimal caching for everything else
188+
- name: "default"
189+
path_pattern: ".*"
190+
headers:
191+
Cache-Control: "public, max-age=1"

internal/api/config.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,6 @@ func (h *ConfigHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
8484

8585
// Set headers.
8686
w.Header().Set("Content-Type", "application/json")
87-
w.Header().Set("Cache-Control", "public, max-age=1, s-maxage=60, stale-while-revalidate=300")
8887

8988
// Encode response
9089
if err := json.NewEncoder(w).Encode(response); err != nil {

internal/config/config.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ type Config struct {
2323
Cartographoor cartographoor.Config `yaml:"cartographoor"`
2424
Bounds BoundsConfig `yaml:"bounds"`
2525
RateLimiting RateLimitingConfig `yaml:"rate_limiting"`
26+
Headers HeadersConfig `yaml:"headers"`
2627
}
2728

2829
// ServerConfig contains HTTP server settings.
@@ -77,6 +78,18 @@ type RateLimitRule struct {
7778
Window time.Duration `yaml:"window"` // Time window
7879
}
7980

81+
// HeadersConfig holds HTTP headers configuration.
82+
type HeadersConfig struct {
83+
Policies []HeaderPolicy `yaml:"policies"`
84+
}
85+
86+
// HeaderPolicy defines headers to set for matching request paths.
87+
type HeaderPolicy struct {
88+
Name string `yaml:"name"` // Policy name for logging/debugging
89+
PathPattern string `yaml:"path_pattern"` // Regex pattern to match request paths
90+
Headers map[string]string `yaml:"headers"` // Headers to set (key: value)
91+
}
92+
8093
// Validate validates the configuration and sets defaults.
8194
func (c *BoundsConfig) Validate() error {
8295
// Set defaults

internal/config/headers_test.go

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
package config
2+
3+
import (
4+
"os"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
"gopkg.in/yaml.v3"
10+
)
11+
12+
func TestHeadersConfig_Loading(t *testing.T) {
13+
yamlConfig := `
14+
server:
15+
port: 8080
16+
host: "0.0.0.0"
17+
read_timeout: 30s
18+
write_timeout: 30s
19+
shutdown_timeout: 10s
20+
log_level: "info"
21+
22+
redis:
23+
address: "localhost:6379"
24+
password: ""
25+
db: 0
26+
dial_timeout: 5s
27+
read_timeout: 3s
28+
write_timeout: 3s
29+
pool_size: 10
30+
31+
leader:
32+
lock_key: "lab:leader:lock"
33+
lock_ttl: 30s
34+
renew_interval: 10s
35+
retry_interval: 5s
36+
37+
cartographoor:
38+
source_url: "https://example.com/networks.json"
39+
refresh_interval: 5m
40+
request_timeout: 30s
41+
networks_ttl: 0s
42+
43+
bounds:
44+
refresh_interval: 10s
45+
request_timeout: 30s
46+
bounds_ttl: 0s
47+
48+
rate_limiting:
49+
enabled: false
50+
51+
headers:
52+
policies:
53+
- name: "static_assets"
54+
path_pattern: "\\.(js|css|png)$"
55+
headers:
56+
Cache-Control: "public, max-age=31536000, immutable"
57+
Vary: "Accept-Encoding"
58+
59+
- name: "api_config"
60+
path_pattern: "^/api/v1/config$"
61+
headers:
62+
Cache-Control: "public, max-age=1, s-maxage=60"
63+
64+
- name: "default"
65+
path_pattern: ".*"
66+
headers:
67+
Cache-Control: "public, max-age=1"
68+
`
69+
70+
var cfg Config
71+
72+
err := yaml.Unmarshal([]byte(yamlConfig), &cfg)
73+
require.NoError(t, err)
74+
75+
// Verify headers config loaded
76+
assert.Len(t, cfg.Headers.Policies, 3)
77+
78+
// Verify first policy
79+
assert.Equal(t, "static_assets", cfg.Headers.Policies[0].Name)
80+
assert.Equal(t, `\.(js|css|png)$`, cfg.Headers.Policies[0].PathPattern)
81+
assert.Len(t, cfg.Headers.Policies[0].Headers, 2)
82+
assert.Equal(t, "public, max-age=31536000, immutable", cfg.Headers.Policies[0].Headers["Cache-Control"])
83+
assert.Equal(t, "Accept-Encoding", cfg.Headers.Policies[0].Headers["Vary"])
84+
85+
// Verify second policy
86+
assert.Equal(t, "api_config", cfg.Headers.Policies[1].Name)
87+
assert.Equal(t, "^/api/v1/config$", cfg.Headers.Policies[1].PathPattern)
88+
assert.Len(t, cfg.Headers.Policies[1].Headers, 1)
89+
assert.Equal(t, "public, max-age=1, s-maxage=60", cfg.Headers.Policies[1].Headers["Cache-Control"])
90+
91+
// Verify third policy
92+
assert.Equal(t, "default", cfg.Headers.Policies[2].Name)
93+
assert.Equal(t, ".*", cfg.Headers.Policies[2].PathPattern)
94+
}
95+
96+
func TestExampleConfig_LoadsSuccessfully(t *testing.T) {
97+
// Load the actual config.example.yaml file
98+
cfg, err := Load("../../config.example.yaml")
99+
require.NoError(t, err)
100+
101+
// Verify headers section exists and has policies
102+
assert.NotEmpty(t, cfg.Headers.Policies, "config.example.yaml should have header policies")
103+
104+
// Log what we found
105+
t.Logf("Loaded %d header policies from config.example.yaml", len(cfg.Headers.Policies))
106+
107+
for i, policy := range cfg.Headers.Policies {
108+
t.Logf(" Policy %d: %s with %d headers", i+1, policy.Name, len(policy.Headers))
109+
}
110+
}
111+
112+
func TestHeadersConfig_EmptyPolicies(t *testing.T) {
113+
yamlConfig := `
114+
server:
115+
port: 8080
116+
host: "0.0.0.0"
117+
read_timeout: 30s
118+
write_timeout: 30s
119+
shutdown_timeout: 10s
120+
log_level: "info"
121+
122+
redis:
123+
address: "localhost:6379"
124+
dial_timeout: 5s
125+
pool_size: 10
126+
127+
leader:
128+
lock_key: "lab:leader:lock"
129+
lock_ttl: 30s
130+
renew_interval: 10s
131+
retry_interval: 5s
132+
133+
cartographoor:
134+
source_url: "https://example.com/networks.json"
135+
refresh_interval: 5m
136+
request_timeout: 30s
137+
138+
bounds:
139+
refresh_interval: 10s
140+
request_timeout: 30s
141+
142+
rate_limiting:
143+
enabled: false
144+
145+
headers:
146+
policies: []
147+
`
148+
149+
var cfg Config
150+
151+
err := yaml.Unmarshal([]byte(yamlConfig), &cfg)
152+
require.NoError(t, err)
153+
154+
// Empty policies should be fine
155+
assert.Empty(t, cfg.Headers.Policies)
156+
}
157+
158+
func TestHeadersConfig_NoHeadersSection(t *testing.T) {
159+
// Create minimal config without headers section
160+
tmpFile, err := os.CreateTemp("", "config-*.yaml")
161+
require.NoError(t, err)
162+
163+
defer os.Remove(tmpFile.Name())
164+
165+
yamlConfig := `
166+
server:
167+
port: 8080
168+
host: "0.0.0.0"
169+
read_timeout: 30s
170+
write_timeout: 30s
171+
shutdown_timeout: 10s
172+
log_level: "info"
173+
174+
redis:
175+
address: "localhost:6379"
176+
dial_timeout: 5s
177+
read_timeout: 3s
178+
write_timeout: 3s
179+
pool_size: 10
180+
181+
leader:
182+
lock_key: "lab:leader:lock"
183+
lock_ttl: 30s
184+
renew_interval: 10s
185+
retry_interval: 5s
186+
187+
cartographoor:
188+
source_url: "https://example.com/networks.json"
189+
refresh_interval: 5m
190+
request_timeout: 30s
191+
192+
bounds:
193+
refresh_interval: 10s
194+
request_timeout: 30s
195+
196+
rate_limiting:
197+
enabled: false
198+
`
199+
200+
_, err = tmpFile.WriteString(yamlConfig)
201+
require.NoError(t, err)
202+
tmpFile.Close()
203+
204+
cfg, err := Load(tmpFile.Name())
205+
require.NoError(t, err)
206+
207+
// Missing headers section should result in empty policies
208+
assert.Empty(t, cfg.Headers.Policies)
209+
}

internal/frontend/frontend.go

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -173,8 +173,7 @@ func (f *Frontend) serveIndex(w http.ResponseWriter, r *http.Request) {
173173
"content_length": len(html),
174174
}).Debug("Serving route-specific cached index.html")
175175

176-
// Set cache headers for index.html (contains config and bounds data)
177-
w.Header().Set("Cache-Control", "public, max-age=1, s-maxage=5, stale-while-revalidate=1")
176+
// Set content type for index.html
178177
w.Header().Set("Content-Type", "text/html; charset=utf-8")
179178
w.WriteHeader(http.StatusOK)
180179

@@ -219,12 +218,6 @@ func (f *Frontend) setCacheHeaders(w http.ResponseWriter, filePath string) {
219218
}
220219

221220
w.Header().Set("Content-Type", contentType)
222-
223-
// Static assets get long cache (1 year)
224-
// index.html gets no-cache (handled in serveIndex)
225-
if !strings.HasSuffix(filePath, "index.html") {
226-
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
227-
}
228221
}
229222

230223
// refreshLoop listens for bounds and cartographoor update notifications and refreshes the cached index.html.

0 commit comments

Comments
 (0)