Skip to content

Commit 0bfc5ea

Browse files
committed
initial middleware with default LRU cache
1 parent 5ff0b47 commit 0bfc5ea

10 files changed

Lines changed: 627 additions & 2 deletions

File tree

.github/workflows/go.yml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
name: Go
2+
3+
on:
4+
push:
5+
branches: [ "main" ]
6+
pull_request:
7+
branches: [ "main" ]
8+
9+
jobs:
10+
11+
build:
12+
runs-on: ubuntu-latest
13+
steps:
14+
- uses: actions/checkout@v4
15+
16+
- name: Set up Go
17+
uses: actions/setup-go@v4
18+
with:
19+
go-version: '1.24'
20+
21+
- name: Build
22+
run: go build -v ./...
23+
24+
- name: Test
25+
run: go test -p 1 ./... -coverprofile=coverage.out
26+
27+
- name: Upload to Codecov
28+
uses: codecov/codecov-action@v4
29+
with:
30+
token: ${{ secrets.CODECOV_TOKEN }}
31+
files: coverage.out

.gitignore

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,5 +28,9 @@ go.work.sum
2828
.env
2929

3030
# Editor/IDE
31-
# .idea/
32-
# .vscode/
31+
.idea/
32+
.vscode/
33+
*.xml
34+
*.iml
35+
vendor/
36+
*/vendor/

cache/cache.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package cache
2+
3+
import (
4+
"net/http"
5+
"time"
6+
)
7+
8+
// Store defines the contract for all storage backends.
9+
type Store interface {
10+
Get(key string) (*ResponseCacheEntry, bool)
11+
Set(key string, entry *ResponseCacheEntry) error
12+
Delete(key string) error
13+
Close() error // For graceful shutdown/cleanup
14+
}
15+
16+
// ResponseCacheEntry holds the complete HTTP response data and caching metadata.
17+
type ResponseCacheEntry struct {
18+
StatusCode int
19+
Headers http.Header
20+
Body []byte
21+
CreatedAt time.Time
22+
TTL time.Duration // Time-to-Live (Freshness)
23+
SWR time.Duration // Stale-While-Revalidate window
24+
}
25+
26+
// Size returns the estimated memory footprint in bytes.
27+
func (e *ResponseCacheEntry) Size() int64 {
28+
// Simple size estimation: Body length + headers map/string overhead
29+
bodySize := int64(len(e.Body))
30+
// Heuristic: ~30 bytes per header key/value pair overhead
31+
headerSize := int64(len(e.Headers) * 30)
32+
return bodySize + headerSize
33+
}
34+
35+
// IsStale checks if the entry is past its TTL.
36+
func (e *ResponseCacheEntry) IsStale() bool {
37+
return time.Since(e.CreatedAt) > e.TTL
38+
}
39+
40+
// IsRotten checks if the entry is past its Stale-While-Revalidate window.
41+
func (e *ResponseCacheEntry) IsRotten() bool {
42+
return time.Since(e.CreatedAt) > (e.TTL + e.SWR)
43+
}

cache/in_memory_quota_lru.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package cache
2+
3+
import (
4+
"container/list"
5+
"sync"
6+
)
7+
8+
// lruEntry links the cache key and the entry to the list element.
9+
type lruEntry struct {
10+
key string
11+
size int64
12+
value *ResponseCacheEntry
13+
}
14+
15+
// InMemoryQuotaLRU implements Store with a hard memory limit and LRU policy.
16+
type InMemoryQuotaLRU struct {
17+
mutex sync.RWMutex
18+
// Doubly linked list for LRU order
19+
lru *list.List
20+
cache map[string]*list.Element
21+
// Hard memory limit in bytes
22+
maxBytes int64
23+
// Current total size of all stored items
24+
currentBytes int64
25+
}
26+
27+
// NewInMemoryQuotaLRU creates a new InMemoryQuotaLRU cache.
28+
// maxMB is the memory limit in megabytes.
29+
func NewInMemoryQuotaLRU(maxMB int) Store {
30+
return &InMemoryQuotaLRU{
31+
lru: list.New(),
32+
cache: make(map[string]*list.Element),
33+
maxBytes: int64(maxMB) * 1024 * 1024,
34+
}
35+
}
36+
37+
// Get retrieves an entry and moves it to the front of the list (MRU).
38+
func (lru *InMemoryQuotaLRU) Get(key string) (*ResponseCacheEntry, bool) {
39+
// Step 1: Read lock to check existence
40+
lru.mutex.RLock()
41+
element, ok := lru.cache[key]
42+
lru.mutex.RUnlock()
43+
if !ok {
44+
return nil, false
45+
}
46+
47+
// Step 2: Only lock for moving element to front
48+
lru.mutex.Lock()
49+
lru.lru.MoveToFront(element)
50+
lru.mutex.Unlock()
51+
52+
return element.Value.(*lruEntry).value, true
53+
}
54+
55+
// Set adds or updates an entry, triggering eviction if the hard memory limit is hit.
56+
func (lru *InMemoryQuotaLRU) Set(key string, entry *ResponseCacheEntry) error {
57+
// compute before acquiring lock
58+
itemSize := entry.Size()
59+
60+
lru.mutex.Lock()
61+
defer lru.mutex.Unlock()
62+
63+
if element, ok := lru.cache[key]; ok {
64+
oldEntry := element.Value.(*lruEntry)
65+
lru.currentBytes -= oldEntry.size
66+
oldEntry.size = itemSize
67+
oldEntry.value = entry
68+
lru.currentBytes += itemSize
69+
lru.lru.MoveToFront(element)
70+
} else {
71+
newEntry := &lruEntry{key: key, size: itemSize, value: entry}
72+
element := lru.lru.PushFront(newEntry)
73+
lru.cache[key] = element
74+
lru.currentBytes += itemSize
75+
}
76+
77+
// Eviction
78+
for lru.currentBytes > lru.maxBytes {
79+
lruElement := lru.lru.Back()
80+
if lruElement == nil {
81+
break
82+
}
83+
evictedEntry := lru.lru.Remove(lruElement).(*lruEntry)
84+
delete(lru.cache, evictedEntry.key)
85+
lru.currentBytes -= evictedEntry.size
86+
}
87+
return nil
88+
}
89+
90+
// Delete removes an entry from the cache.
91+
func (lru *InMemoryQuotaLRU) Delete(key string) error {
92+
lru.mutex.Lock()
93+
defer lru.mutex.Unlock()
94+
95+
if element, ok := lru.cache[key]; ok {
96+
evictedEntry := lru.lru.Remove(element).(*lruEntry)
97+
delete(lru.cache, evictedEntry.key)
98+
lru.currentBytes -= evictedEntry.size
99+
}
100+
return nil
101+
}
102+
103+
// Close is a no-op for in-memory, but required by the interface.
104+
func (lru *InMemoryQuotaLRU) Close() error {
105+
return nil
106+
}

config.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package capsulecache
2+
3+
import (
4+
"bytes"
5+
"crypto/sha256"
6+
"encoding/hex"
7+
"io"
8+
"net/http"
9+
"strings"
10+
"time"
11+
)
12+
13+
// Config holds the middleware settings.
14+
type Config struct {
15+
// DefaultTTL is how long a cached entry is considered fresh.
16+
DefaultTTL time.Duration
17+
// DefaultSWR is the stale-while-revalidate window.
18+
DefaultSWR time.Duration
19+
KeyGenerator func(*http.Request) string
20+
// ShouldCache decides whether a response with given status code should be cached.
21+
ShouldCache func(statusCode int) bool
22+
// MaxBodyBytes - do not cache bodies larger than this.
23+
MaxBodyBytes int64
24+
// StripHeaders removes headers before storing (hop-by-hop etc).
25+
StripHeaders func(http.Header) http.Header
26+
}
27+
28+
// DefaultConfig provides defaults.
29+
var DefaultConfig = &Config{
30+
DefaultTTL: 5 * time.Minute,
31+
DefaultSWR: 1 * time.Minute,
32+
KeyGenerator: DefaultKeyGenerator,
33+
ShouldCache: func(statusCode int) bool {
34+
// Only cache successful responses by default
35+
return statusCode >= http.StatusOK && statusCode < http.StatusMultipleChoices
36+
},
37+
StripHeaders: stripHopByHop,
38+
}
39+
40+
// DefaultKeyGenerator creates a simple cache key (Method:Path).
41+
func DefaultKeyGenerator(r *http.Request) string {
42+
return "cache:" + r.Method + ":" + r.URL.Path
43+
}
44+
45+
// AdvancedKeyGenerator includes specified headers and body hash (for POST/PUT).
46+
func AdvancedKeyGenerator(r *http.Request, headersToInclude []string) string {
47+
// 1. Base Key
48+
key := r.Method + ":" + r.URL.Path
49+
50+
// 2. Add Headers
51+
for _, header := range headersToInclude {
52+
if val := r.Header.Get(header); val != "" {
53+
key += ":" + header + "=" + val
54+
}
55+
}
56+
57+
// 3. Add Body Hash (for safe caching of idempotent requests like POST to a search endpoint)
58+
if r.ContentLength > 0 && (r.Method == http.MethodPost || r.Method == http.MethodPut) {
59+
var buf bytes.Buffer
60+
// TeeReader allows reading the body while writing it to buf
61+
tee := io.TeeReader(r.Body, &buf)
62+
bodyBytes, _ := io.ReadAll(tee)
63+
r.Body.Close()
64+
r.Body = io.NopCloser(&buf) // restore body for downstream
65+
66+
hash := sha256.Sum256(bodyBytes)
67+
key += ":body_hash=" + hex.EncodeToString(hash[:])
68+
}
69+
70+
return key
71+
}
72+
73+
func stripHopByHop(header http.Header) http.Header {
74+
// Clone so caller can mutate safely.
75+
headerClone := header.Clone()
76+
77+
for _, k := range []string{
78+
"Connection", "Proxy-Connection", "Keep-Alive",
79+
"Proxy-Authenticate", "Proxy-Authorization", "TE",
80+
"Trailer", "Transfer-Encoding", "Upgrade",
81+
} {
82+
headerClone.Del(k)
83+
}
84+
// Also remove hop-by-hop values referenced by Connection header
85+
if conn := header.Get("Connection"); conn != "" {
86+
for _, token := range strings.Split(conn, ",") {
87+
token = strings.TrimSpace(token)
88+
if token != "" {
89+
headerClone.Del(token)
90+
}
91+
}
92+
}
93+
return headerClone
94+
}

go.mod

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
module github.com/spdeepak/capsulecache
2+
3+
go 1.24.0
4+
5+
toolchain go1.24.7
6+
7+
require golang.org/x/sync v0.18.0

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
2+
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=

0 commit comments

Comments
 (0)