Skip to content

Commit 049e089

Browse files
committed
feat: add android-sdk caching strategy
Add a dedicated Android SDK caching strategy that handles the Android SDK protocol where XML feed/manifest files are mutable and require short TTLs, while archive downloads (.zip files) are immutable and can be cached permanently. The existing proxy strategy does not distinguish between these different cache characteristics, so a dedicated strategy is needed to properly cache SDK feeds with configurable TTLs while permanently caching archives. The strategy: - Routes requests through /android-sdk/{host}/{path...} - Reconstructs the original HTTPS URL and proxies the request - Uses configurable FeedTTL (default 1h) for XML files - Uses permanent caching (TTL=0) for archive files - Follows the same handler.New builder pattern as other strategies
1 parent 9e485bf commit 049e089

4 files changed

Lines changed: 284 additions & 0 deletions

File tree

cachew.hcl

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ strategy gomod {
5151

5252
strategy hermit { }
5353

54+
strategy android-sdk { }
55+
5456
strategy proxy { }
5557

5658
cache disk {

cmd/cachewd/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ func newRegistries(
141141
metadatadb.RegisterS3(mr, s3ClientProvider)
142142

143143
sr := strategy.NewRegistry()
144+
strategy.RegisterAndroidSDK(sr)
144145
strategy.RegisterAPIV1(sr)
145146
strategy.RegisterArtifactory(sr)
146147
strategy.RegisterGitHubReleases(sr, tokenManagerProvider)

internal/strategy/android_sdk.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package strategy
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"net/url"
7+
"strings"
8+
"time"
9+
10+
"github.com/block/cachew/internal/cache"
11+
"github.com/block/cachew/internal/logging"
12+
"github.com/block/cachew/internal/strategy/handler"
13+
)
14+
15+
// RegisterAndroidSDK registers the Android SDK caching strategy.
16+
func RegisterAndroidSDK(r *Registry) {
17+
Register(r, "android-sdk", "Caches Android SDK package downloads.", NewAndroidSDK)
18+
}
19+
20+
// AndroidSDKConfig holds configuration for the Android SDK caching strategy.
21+
//
22+
// In HCL it looks something like this:
23+
//
24+
// android-sdk {
25+
// feed-ttl = "1h"
26+
// }
27+
type AndroidSDKConfig struct {
28+
// FeedTTL controls how long mutable feed/manifest XML files are cached.
29+
// Archive downloads (.zip) are cached permanently. The Android SDK protocol
30+
// uses XML for all mutable manifests and .zip for all immutable archives.
31+
FeedTTL time.Duration `hcl:"feed-ttl,optional" help:"Cache TTL for mutable SDK feed XML files" default:"1h"`
32+
}
33+
34+
// AndroidSDK caches Android SDK downloads. It routes all requests through
35+
// /android-sdk/{host}/{path...}, reconstructing the original URL and caching
36+
// the response. XML feeds get a short TTL; zip archives are cached permanently.
37+
type AndroidSDK struct {
38+
config AndroidSDKConfig
39+
cache cache.Cache
40+
client *http.Client
41+
}
42+
43+
var _ Strategy = (*AndroidSDK)(nil)
44+
45+
// NewAndroidSDK creates and registers the Android SDK strategy.
46+
func NewAndroidSDK(ctx context.Context, config AndroidSDKConfig, c cache.Cache, mux Mux) (*AndroidSDK, error) {
47+
logger := logging.FromContext(ctx)
48+
49+
s := &AndroidSDK{
50+
config: config,
51+
cache: c,
52+
client: &http.Client{},
53+
}
54+
55+
hdlr := handler.New(s.client, c).
56+
CacheKey(func(r *http.Request) string {
57+
return s.buildOriginalURL(r)
58+
}).
59+
TTL(func(r *http.Request) time.Duration {
60+
if strings.HasSuffix(r.URL.Path, ".xml") {
61+
return s.config.FeedTTL
62+
}
63+
return 0
64+
}).
65+
Transform(func(r *http.Request) (*http.Request, error) {
66+
originalURL := s.buildOriginalURL(r)
67+
return http.NewRequestWithContext(r.Context(), http.MethodGet, originalURL, nil)
68+
})
69+
70+
mux.Handle("GET /android-sdk/{host}/{path...}", hdlr)
71+
logger.InfoContext(ctx, "Android SDK strategy initialized", "feed_ttl", config.FeedTTL)
72+
return s, nil
73+
}
74+
75+
// String implements the Strategy interface.
76+
func (s *AndroidSDK) String() string { return "android-sdk" }
77+
78+
func (s *AndroidSDK) buildOriginalURL(r *http.Request) string {
79+
host := r.PathValue("host")
80+
path := r.PathValue("path")
81+
if !strings.HasPrefix(path, "/") {
82+
path = "/" + path
83+
}
84+
return (&url.URL{Scheme: "https", Host: host, Path: path, RawQuery: r.URL.RawQuery}).String()
85+
}
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
package strategy_test
2+
3+
import (
4+
"context"
5+
"io"
6+
"log/slog"
7+
"net/http"
8+
"net/http/httptest"
9+
"sync"
10+
"testing"
11+
"time"
12+
13+
"github.com/alecthomas/assert/v2"
14+
15+
"github.com/block/cachew/internal/cache"
16+
"github.com/block/cachew/internal/logging"
17+
"github.com/block/cachew/internal/strategy"
18+
)
19+
20+
// httpTransportMutexAndroidSDK ensures android-sdk tests don't run in parallel
21+
// since they modify the global http.DefaultTransport
22+
var httpTransportMutexAndroidSDK sync.Mutex //nolint:gochecknoglobals
23+
24+
type mockAndroidSDKTransport struct {
25+
backend *httptest.Server
26+
originalTransport http.RoundTripper
27+
}
28+
29+
func (m *mockAndroidSDKTransport) RoundTrip(req *http.Request) (*http.Response, error) {
30+
if req.Host == "example.com" {
31+
newReq := req.Clone(req.Context())
32+
newReq.URL.Scheme = "http"
33+
newReq.URL.Host = m.backend.Listener.Addr().String()
34+
return m.originalTransport.RoundTrip(newReq)
35+
}
36+
return m.originalTransport.RoundTrip(req)
37+
}
38+
39+
// ttlSpyCache wraps a cache and records the TTL passed to each Create call.
40+
type ttlSpyCache struct {
41+
cache.Cache
42+
mu sync.Mutex
43+
ttls []time.Duration
44+
}
45+
46+
func (c *ttlSpyCache) Create(ctx context.Context, key cache.Key, headers http.Header, ttl time.Duration) (io.WriteCloser, error) {
47+
c.mu.Lock()
48+
c.ttls = append(c.ttls, ttl)
49+
c.mu.Unlock()
50+
return c.Cache.Create(ctx, key, headers, ttl)
51+
}
52+
53+
func setupAndroidSDKWithSpy(t *testing.T, feedTTL time.Duration, backend *httptest.Server) (*http.ServeMux, *ttlSpyCache, context.Context) {
54+
t.Helper()
55+
56+
httpTransportMutexAndroidSDK.Lock()
57+
t.Cleanup(httpTransportMutexAndroidSDK.Unlock)
58+
59+
originalTransport := http.DefaultTransport
60+
t.Cleanup(func() { http.DefaultTransport = originalTransport }) //nolint:reassign
61+
http.DefaultTransport = &mockAndroidSDKTransport{backend: backend, originalTransport: originalTransport} //nolint:reassign
62+
63+
_, ctx := logging.Configure(context.Background(), logging.Config{Level: slog.LevelError})
64+
memCache, err := cache.NewMemory(ctx, cache.MemoryConfig{MaxTTL: time.Hour})
65+
assert.NoError(t, err)
66+
t.Cleanup(func() { memCache.Close() })
67+
68+
spy := &ttlSpyCache{Cache: memCache}
69+
mux := http.NewServeMux()
70+
_, err = strategy.NewAndroidSDK(ctx, strategy.AndroidSDKConfig{FeedTTL: feedTTL}, spy, mux)
71+
assert.NoError(t, err)
72+
73+
return mux, spy, ctx
74+
}
75+
76+
func TestAndroidSDKTTLByFileType(t *testing.T) {
77+
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
78+
w.WriteHeader(http.StatusOK)
79+
_, _ = w.Write([]byte("response"))
80+
}))
81+
defer backend.Close()
82+
83+
feedTTL := 30 * time.Minute
84+
mux, spy, ctx := setupAndroidSDKWithSpy(t, feedTTL, backend)
85+
86+
tests := []struct {
87+
name string
88+
path string
89+
expectedTTL time.Duration
90+
}{
91+
{"XML feed gets FeedTTL", "/android-sdk/example.com/repository2-3.xml", feedTTL},
92+
{"ZIP archive gets permanent cache", "/android-sdk/example.com/platform-36.zip", 0},
93+
{"TXT file gets permanent cache", "/android-sdk/example.com/checksums.txt", 0},
94+
{"JAR file gets permanent cache", "/android-sdk/example.com/some-tool.jar", 0},
95+
}
96+
97+
for i, tt := range tests {
98+
req := httptest.NewRequestWithContext(ctx, http.MethodGet, tt.path, nil)
99+
w := httptest.NewRecorder()
100+
mux.ServeHTTP(w, req)
101+
assert.Equal(t, http.StatusOK, w.Code, tt.name)
102+
103+
spy.mu.Lock()
104+
assert.Equal(t, tt.expectedTTL, spy.ttls[i], tt.name)
105+
spy.mu.Unlock()
106+
}
107+
}
108+
109+
func TestAndroidSDKCaching(t *testing.T) {
110+
callCount := 0
111+
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
112+
callCount++
113+
w.WriteHeader(http.StatusOK)
114+
_, _ = w.Write([]byte("cached-content"))
115+
}))
116+
defer backend.Close()
117+
118+
mux, _, ctx := setupAndroidSDKWithSpy(t, time.Hour, backend)
119+
120+
// First request: cache miss
121+
req1 := httptest.NewRequestWithContext(ctx, http.MethodGet, "/android-sdk/example.com/sdk/platform-36_r02.zip", nil)
122+
w1 := httptest.NewRecorder()
123+
mux.ServeHTTP(w1, req1)
124+
assert.Equal(t, http.StatusOK, w1.Code)
125+
assert.Equal(t, 1, callCount)
126+
127+
// Second request: cache hit, no additional backend call
128+
req2 := httptest.NewRequestWithContext(ctx, http.MethodGet, "/android-sdk/example.com/sdk/platform-36_r02.zip", nil)
129+
w2 := httptest.NewRecorder()
130+
mux.ServeHTTP(w2, req2)
131+
assert.Equal(t, http.StatusOK, w2.Code)
132+
assert.Equal(t, 1, callCount, "should be served from cache")
133+
assert.Equal(t, "cached-content", w2.Body.String())
134+
}
135+
136+
func TestAndroidSDKURLReconstruction(t *testing.T) {
137+
var receivedURL string
138+
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
139+
receivedURL = r.RequestURI
140+
w.WriteHeader(http.StatusOK)
141+
_, _ = w.Write([]byte("ok"))
142+
}))
143+
defer backend.Close()
144+
145+
mux, _, ctx := setupAndroidSDKWithSpy(t, time.Hour, backend)
146+
147+
tests := []struct {
148+
name string
149+
requestPath string
150+
expectedURI string
151+
}{
152+
{"simple path", "/android-sdk/example.com/path/to/resource.zip", "/path/to/resource.zip"},
153+
{"with query params", "/android-sdk/example.com/repo.xml?v=2&channel=stable", "/repo.xml?v=2&channel=stable"},
154+
}
155+
156+
for _, tt := range tests {
157+
receivedURL = ""
158+
req := httptest.NewRequestWithContext(ctx, http.MethodGet, tt.requestPath, nil)
159+
w := httptest.NewRecorder()
160+
mux.ServeHTTP(w, req)
161+
assert.Equal(t, http.StatusOK, w.Code, tt.name)
162+
assert.Equal(t, tt.expectedURI, receivedURL, tt.name)
163+
}
164+
}
165+
166+
func TestAndroidSDKMultipleFeedTypes(t *testing.T) {
167+
callCount := 0
168+
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
169+
callCount++
170+
w.WriteHeader(http.StatusOK)
171+
_, _ = w.Write([]byte("response"))
172+
}))
173+
defer backend.Close()
174+
175+
mux, _, ctx := setupAndroidSDKWithSpy(t, time.Hour, backend)
176+
177+
paths := []string{
178+
"/android-sdk/example.com/repository2-3.xml",
179+
"/android-sdk/example.com/platform-36.zip",
180+
"/android-sdk/example.com/checksums.txt",
181+
}
182+
183+
for i, path := range paths {
184+
req := httptest.NewRequestWithContext(ctx, http.MethodGet, path, nil)
185+
w := httptest.NewRecorder()
186+
mux.ServeHTTP(w, req)
187+
assert.Equal(t, http.StatusOK, w.Code)
188+
assert.Equal(t, i+1, callCount)
189+
190+
// Second request should always be cached
191+
req2 := httptest.NewRequestWithContext(ctx, http.MethodGet, path, nil)
192+
w2 := httptest.NewRecorder()
193+
mux.ServeHTTP(w2, req2)
194+
assert.Equal(t, i+1, callCount, "path %s should be served from cache", path)
195+
}
196+
}

0 commit comments

Comments
 (0)