diff --git a/cache.go b/cache.go new file mode 100644 index 0000000..32aada4 --- /dev/null +++ b/cache.go @@ -0,0 +1,113 @@ +package httpcache + +import ( + "bufio" + "bytes" + "net/http" + "sort" + "strings" +) + +// Cache implements the basic mechanism to store and retrieve responses. +type Cache interface { + // Get returns the []byte representation of a cached response and a boolean + // indicating whether the response was found in the cache. + Get(string) ([]byte, bool) + + // Put stores the []byte representation of a response in the cache with a key. + Put(string, []byte) + + // Rm removes the cached response associated with the key. + Rm(string) +} + +// CachedResponse returns the cached http.Response for the request if present and nil +// otherwise. Used to quickly create a client-side response from the cache. +func CachedResponse(cache Cache, req *http.Request) (rep *http.Response, err error) { + val, ok := cache.Get(cacheKey(req)) + if !ok { + return nil, nil + } + + buf := bytes.NewBuffer(val) + return http.ReadResponse(bufio.NewReader(buf), req) +} + +// cachedResponse is an internal function that creates an http.Response from a cached +// value as returned by the specified key. Used internally by the Transport to handle +// headers and vary keys. +func cachedResponse(cache Cache, key string, req *http.Request) (rep *http.Response, err error) { + val, ok := cache.Get(key) + if !ok { + return nil, nil + } + + buf := bytes.NewBuffer(val) + return http.ReadResponse(bufio.NewReader(buf), req) +} + +// cacheKey returns the cache key for the given request. +func cacheKey(req *http.Request) string { + if req.Method == http.MethodGet { + return req.URL.String() + } + return req.Method + " " + req.URL.String() +} + +// cacheKeyWithHeaders returns the cach key for a request and includes the specified +// headers in their canonical form. This allows you to differentiate cache entries +// based on header values such as Authorization or custom headers. +func cacheKeyWithHeaders(req *http.Request, headers []string) string { + key := cacheKey(req) + + if len(headers) == 0 { + return key + } + + // Append header values to the key if headers are specified + parts := make([]string, 0, len(headers)) + for _, header := range headers { + canonical := http.CanonicalHeaderKey(header) + if value := normalize(req.Header.Get(canonical)); value != "" { + parts = append(parts, canonical+":"+value) + } + } + + if len(parts) > 0 { + // Sort header parts to ensure consistent ordering + sort.Strings(parts) + key = key + "|" + strings.Join(parts, "|") + } + + return key +} + +// cacheKeyWithVary returns the cache key for a request, including Vary headers from +// the cached response. This implements RFC 9111 vary seperation. Header values are +// normalized before inclusion in the cache key. +func cacheKeyWithVary(req *http.Request, varyHeaders []string) string { + key := cacheKey(req) + + if len(varyHeaders) == 0 { + return key + } + + parts := make([]string, 0, len(varyHeaders)) + for _, header := range varyHeaders { + canonical := http.CanonicalHeaderKey(header) + if canonical == "" || canonical == "*" { + continue + } + + value := normalize(req.Header.Get(canonical)) + parts = append(parts, canonical+":"+value) + } + + if len(parts) > 0 { + // Sort header parts to ensure consistent ordering + sort.Strings(parts) + key = key + "|vary:" + strings.Join(parts, "|") + } + + return key +} diff --git a/cache_test.go b/cache_test.go new file mode 100644 index 0000000..430c408 --- /dev/null +++ b/cache_test.go @@ -0,0 +1,180 @@ +package httpcache_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + "go.rtnl.ai/httpcache" +) + +func TestCacheKey(t *testing.T) { + tests := []struct { + name string + request *TestRequest + expected string + }{ + { + name: "Simple GET Request", + request: &TestRequest{method: "GET", url: "http://example.com/resource"}, + expected: "http://example.com/resource", + }, + { + name: "Simple POST Request", + request: &TestRequest{method: "POST", url: "http://example.com/resource"}, + expected: "POST http://example.com/resource", + }, + { + name: "GET Request with Query Params", + request: &TestRequest{method: "GET", url: "https://example.com/resource?id=123"}, + expected: "https://example.com/resource?id=123", + }, + { + name: "PUT Request with Query Params", + request: &TestRequest{method: "PUT", url: "https://example.com/resource?id=123"}, + expected: "PUT https://example.com/resource?id=123", + }, + } + + for _, test := range tests { + result := httpcache.CacheKey(test.request.HTTP()) + require.Equal(t, test.expected, result, "Test Case: %q", test.name) + } +} + +func TestCacheKeyWithHeaders(t *testing.T) { + tests := []struct { + name string + request *TestRequest + headers []string + expected string + }{ + { + name: "No headers (nil)", + request: &TestRequest{ + method: "GET", + url: "http://example.com/resource", + headers: map[string]string{"Accept": " application/json ", "Accept-Language": "en-US, fr"}}, + headers: nil, + expected: "http://example.com/resource", + }, + { + name: "No headers (empty)", + request: &TestRequest{ + method: "GET", + url: "http://example.com/resource", + headers: map[string]string{"Accept": " application/json ", "Accept-Language": "en-US, fr"}}, + headers: []string{}, + expected: "http://example.com/resource", + }, + { + name: "With headers, unnormalized values", + request: &TestRequest{ + method: "GET", + url: "http://example.com/resource", + headers: map[string]string{"Accept": " application/json ", "Accept-Language": "en-US, fr"}}, + headers: []string{"Accept", "Accept-Language"}, + expected: "http://example.com/resource|Accept-Language:en-US,fr|Accept:application/json", + }, + { + name: "With headers, request missing headers", + request: &TestRequest{ + method: "GET", + url: "http://example.com/resource", + headers: map[string]string{"Accept-Language": "en,fr"}}, + headers: []string{"Accept", "Accept-Language", "Authorization"}, + expected: "http://example.com/resource|Accept-Language:en,fr", + }, + { + name: "With headers, not canonicalized", + request: &TestRequest{ + method: "GET", + url: "http://example.com/resource", + headers: map[string]string{"accept-language": "en,fr"}}, + headers: []string{"accept-language"}, + expected: "http://example.com/resource|Accept-Language:en,fr", + }, + } + + for _, test := range tests { + result := httpcache.CacheKeyWithHeaders(test.request.HTTP(), test.headers) + require.Equal(t, test.expected, result, "Test Case: %q", test.name) + } +} + +func TestCacheKeyWithVary(t *testing.T) { + tests := []struct { + name string + request *TestRequest + headers []string + expected string + }{ + { + name: "No Vary headers (nil)", + request: &TestRequest{ + method: "GET", + url: "http://example.com/resource", + headers: map[string]string{"Accept": "text/html", "Accept-Language": "en, fr"}}, + headers: nil, + expected: "http://example.com/resource", + }, + { + name: "No Vary headers (empty)", + request: &TestRequest{ + method: "GET", + url: "http://example.com/resource", + headers: map[string]string{"Accept": "text/html", "Accept-Language": "en, fr"}}, + headers: []string{}, + expected: "http://example.com/resource", + }, + { + name: "With Vary headers, unnormalized values", + request: &TestRequest{ + method: "GET", + url: "http://example.com/resource", + headers: map[string]string{"Accept": " text/html ", "Accept-Language": "en-US, fr"}}, + headers: []string{"Accept", "Accept-Language"}, + expected: "http://example.com/resource|vary:Accept-Language:en-US,fr|Accept:text/html", + }, + { + name: "With Vary headers, request missing headers", + request: &TestRequest{ + method: "GET", + url: "http://example.com/resource", + headers: map[string]string{"Accept-Language": "en,fr"}}, + headers: []string{"Accept", "Accept-Language", "Authorization"}, + expected: "http://example.com/resource|vary:Accept-Language:en,fr|Accept:|Authorization:", + }, + { + name: "With Vary headers, not canonicalized", + request: &TestRequest{ + method: "GET", + url: "http://example.com/resource", + headers: map[string]string{"accept-language": "en,fr"}}, + headers: []string{"accept-language"}, + expected: "http://example.com/resource|vary:Accept-Language:en,fr", + }, + { + name: "With Vary headers, wildcard ignored", + request: &TestRequest{ + method: "GET", + url: "http://example.com/resource", + headers: map[string]string{"Accept": "text/html", "Accept-Language": "en,fr"}}, + headers: []string{"*", "Accept", "Accept-Language"}, + expected: "http://example.com/resource|vary:Accept-Language:en,fr|Accept:text/html", + }, + { + name: "With Vary headers, empty header name ignored", + request: &TestRequest{ + method: "GET", + url: "http://example.com/resource", + headers: map[string]string{"Accept": "text/html", "Accept-Language": "en,fr"}}, + headers: []string{"", "Accept", "Accept-Language"}, + expected: "http://example.com/resource|vary:Accept-Language:en,fr|Accept:text/html", + }, + } + + for _, test := range tests { + result := httpcache.CacheKeyWithVary(test.request.HTTP(), test.headers) + require.Equal(t, test.expected, result, "Test Case: %q", test.name) + } +} diff --git a/export_test.go b/export_test.go new file mode 100644 index 0000000..e57e73c --- /dev/null +++ b/export_test.go @@ -0,0 +1,9 @@ +package httpcache + +var ( + CacheKey = cacheKey + CacheKeyWithHeaders = cacheKeyWithHeaders + CacheKeyWithVary = cacheKeyWithVary + Normalize = normalize + CachedResponseWithKey = cachedResponse +) diff --git a/go.mod b/go.mod index a239f6e..e6e48a0 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,11 @@ module go.rtnl.ai/httpcache go 1.25.1 + +require github.com/stretchr/testify v1.11.1 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c4c1710 --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/httpcache.go b/httpcache.go index de5ad6e..00faa77 100644 --- a/httpcache.go +++ b/httpcache.go @@ -1,8 +1,44 @@ package httpcache -import "net/http" +import ( + "net/http" + "strings" +) -type Cache interface{} +const ( + nbsp = ' ' +) + +func normalize(value string) string { + // Trim leading/trailing whitespace + value = strings.TrimSpace(value) + + // Normalize all whitespace sequences to a single space + var ( + norm strings.Builder + prevSpace bool + ) + + for _, c := range value { + if c == nbsp || c == '\t' || c == '\n' || c == '\r' { + if !prevSpace { + norm.WriteRune(nbsp) + prevSpace = true + } + } else { + norm.WriteRune(c) + prevSpace = false + } + } + + // Normalize comma-separated values (e.g. en,fr and en, fr should match) + result := strings.ReplaceAll(norm.String(), ", ", ",") + return result +} + +//=========================================================================== +// Transport +//=========================================================================== type Transport struct { Transport http.RoundTripper diff --git a/httpcache_test.go b/httpcache_test.go new file mode 100644 index 0000000..4d9e9a4 --- /dev/null +++ b/httpcache_test.go @@ -0,0 +1,64 @@ +package httpcache_test + +import ( + "bytes" + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + "go.rtnl.ai/httpcache" +) + +//=========================================================================== +// Testing Helpers +//===========================================================================s + +type TestRequest struct { + method string + url string + body []byte + headers map[string]string +} + +func (tr *TestRequest) HTTP() *http.Request { + if tr.method == "" { + tr.method = http.MethodGet + } + + var body io.Reader + if len(tr.body) > 0 { + body = bytes.NewReader(tr.body) + } + + req, _ := http.NewRequest(tr.method, tr.url, body) + for k, v := range tr.headers { + req.Header.Set(k, v) + } + + return req +} + +//=========================================================================== +// Package Helpers Testing +//=========================================================================== + +func TestNormalize(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {" Hello World ", "Hello World"}, // Leading/trailing and multiple spaces + {"Line1\nLine2\r\nLine3", "Line1 Line2 Line3"}, // Newlines and carriage returns + {"Value1, Value2,Value3", "Value1,Value2,Value3"}, // Comma-separated values + {"\tTabbed\tText\t", "Tabbed Text"}, // Tabs + {" Value1,\tValue2,\t\t\tValue3", "Value1,Value2,Value3"}, // Mixed whitespace in comma-separated values + {"Single Value", "Single Value"}, // No normalization needed + {"", ""}, // Empty string + } + + for _, test := range tests { + result := httpcache.Normalize(test.input) + require.Equal(t, test.expected, result) + } +} diff --git a/inmem.go b/inmem.go new file mode 100644 index 0000000..d4ba291 --- /dev/null +++ b/inmem.go @@ -0,0 +1,36 @@ +package httpcache + +import "sync" + +// InMemoryCache is an implementation of Cache that stores responses in an in-memory +// map. This cache if volatile and will be cleared when the program exits, but is often +// a good choice for testing or short-lived applications. +type InMemoryCache struct { + sync.RWMutex + store map[string][]byte +} + +// Get the []byte representation of the response and true if present. +func (c *InMemoryCache) Get(key string) (val []byte, ok bool) { + c.RLock() + val, ok = c.store[key] + c.RUnlock() + return +} + +// Put stores the []byte representation of the response with the specified key. +func (c *InMemoryCache) Put(key string, val []byte) { + c.Lock() + if c.store == nil { + c.store = make(map[string][]byte) + } + c.store[key] = val + c.Unlock() +} + +// Rm removes the cached response associated with the key. +func (c *InMemoryCache) Rm(key string) { + c.Lock() + delete(c.store, key) + c.Unlock() +} diff --git a/logger.go b/logger.go new file mode 100644 index 0000000..695eadc --- /dev/null +++ b/logger.go @@ -0,0 +1,28 @@ +package httpcache + +import ( + "log/slog" + "sync" +) + +var ( + logger *slog.Logger + loggerMu sync.Once +) + +// SetLogger sets a custom slog.Logger instance to be used by httpcache. If not set, +// the default slog logger will be used. Rotational apps should implement a zerolog +// slogger for observability. +func SetLogger(l *slog.Logger) { + logger = l +} + +// GetLogger returns the configured logger or the default slog logger. +func GetLogger() *slog.Logger { + loggerMu.Do(func() { + if logger == nil { + logger = slog.Default() + } + }) + return logger +}