Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions cache.go
Original file line number Diff line number Diff line change
@@ -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
}
180 changes: 180 additions & 0 deletions cache_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
9 changes: 9 additions & 0 deletions export_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package httpcache

var (
CacheKey = cacheKey
CacheKeyWithHeaders = cacheKeyWithHeaders
CacheKeyWithVary = cacheKeyWithVary
Normalize = normalize
CachedResponseWithKey = cachedResponse
)
8 changes: 8 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -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
)
10 changes: 10 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
40 changes: 38 additions & 2 deletions httpcache.go
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading
Loading