Skip to content

Commit

Permalink
feat: Implement cache interceptor middleware
Browse files Browse the repository at this point in the history
* test: Add tests of cache interceptor using httptest package

* refactor: Decrease test ttl time for memcache_test
  • Loading branch information
mehmetumit committed Nov 5, 2023
1 parent f0b6749 commit 719cd7a
Show file tree
Hide file tree
Showing 3 changed files with 350 additions and 1 deletion.
2 changes: 1 addition & 1 deletion internal/adapters/memcache/memcache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ func TestMemCache_GenKey_Set_Get_GetNotFound_Flush_ExpireAfter(t *testing.T) {

})
t.Run("Expire After", func(t *testing.T) {
ttl := 500 * time.Millisecond
ttl := 50 * time.Millisecond
for k, v := range cacheMap {
hashKey, err := memCache.GenKey(ctx, k)
if err != nil {
Expand Down
77 changes: 77 additions & 0 deletions internal/adapters/rest/middleware/cache_interceptor.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package middleware

import (
"net/http"
"time"

"github.com/mehmetumit/dexus/internal/core/ports"
)


type CacheInterceptor struct {
cacher ports.Cacher
logger ports.Logger
ttl time.Duration
}

func NewCacheInterceptor(c ports.Cacher, l ports.Logger, ttl time.Duration) CacheInterceptor {
return CacheInterceptor{
cacher: c,
logger: l,
ttl: ttl,
}

}
func (ch *CacheInterceptor) InterceptHandler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
proxyWriter := NewProxyResponseWriter(w)
defer func() {
// Don't cache observability paths and some http methods
if r.URL.Path == "/health" ||
r.URL.Path == "/metrics" ||
r.URL.Path == "/monitor" ||
r.Method == http.MethodOptions || r.Method == http.MethodPost {
ch.logger.Debug("Pass caching")
next.ServeHTTP(w, r)
return
}
ctx := r.Context()

hashURL, _ := ch.cacher.GenKey(ctx, r.URL.Path)
// The method is not a query, which means a state change occurs on result of this URL
if r.Method != http.MethodGet {
//Invalidate cache
next.ServeHTTP(w, r)
err := ch.cacher.Delete(ctx, hashURL)
ch.logger.Debug("Invalidate cache:", hashURL)
if err != nil {
ch.logger.Error("command http method cache invalidation err:", err)
}
return
}
cacheData, err := ch.cacher.Get(ctx, hashURL)

if err != nil || len(cacheData) == 0 {
ch.logger.Debug("Cache miss:", hashURL)
if err != ports.ErrKeyNotFound{
ch.logger.Error("internal cache error:",err)
}
// Get response of request by sending it to next
next.ServeHTTP(proxyWriter, r)
if proxyWriter.StatusCode == http.StatusFound {
// Set cache using redirection location which stored in proxyWriter
ch.cacher.Set(ctx, hashURL, proxyWriter.GetLocation(), ch.ttl)
}
return// Response already built using proxyWriter
}
// Don't send the request to the next
//Instead, respond to the client with cached data
ch.logger.Debug("Cache hit:", hashURL)
ch.logger.Debug("Cached data:", cacheData)
w.Header().Add("x-cached-response", "true")

http.Redirect(w, r, cacheData, http.StatusFound)
return
}()
})
}
272 changes: 272 additions & 0 deletions internal/adapters/rest/middleware/cache_interceptor_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
package middleware

import (
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/mehmetumit/dexus/internal/mocks"
)

func newTestCacheInterceptor(tb testing.TB, ttl time.Duration) CacheInterceptor {
tb.Helper()
c := mocks.NewMockCacher()
l := mocks.NewMockLogger()
return NewCacheInterceptor(c, l, ttl)
}

type passInterceptorConfig struct {
interceptor *CacheInterceptor
reqPath string
reqMethod string
expectedURL string
expectedStatus int
}

func genSinglePassInterceptorResult(t *testing.T, cfg passInterceptorConfig) *httptest.ResponseRecorder {
t.Helper()
mockResp := httptest.NewRecorder()
mockReq := httptest.NewRequest(cfg.reqMethod, "https://foo.com"+cfg.reqPath, nil)
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, string(cfg.expectedURL), cfg.expectedStatus)
})

interceptHandler := cfg.interceptor.InterceptHandler(nextHandler)
interceptHandler.ServeHTTP(mockResp, mockReq)
return mockResp
}
func genDoublePassInterceptorResult(t *testing.T, cfg passInterceptorConfig) *httptest.ResponseRecorder {
t.Helper()
// Run twice to check caching
// Return only second response
genSinglePassInterceptorResult(t, cfg)
return genSinglePassInterceptorResult(t, cfg)
}

type chechCachedConfig struct {
reqPath string
expectedURL string
expectedStatus int
}

func errorIfCached(t *testing.T, mockResp *httptest.ResponseRecorder, cfg chechCachedConfig) {
t.Helper()
if mockResp.Header().Get("x-cached-response") != "" {
t.Errorf("path %s must not be cached", cfg.reqPath)
}
if mockResp.Header().Get("location") != cfg.expectedURL {
t.Errorf("expected location data %s, got %s", cfg.expectedURL, mockResp.Header().Get("location"))
}
if mockResp.Code != cfg.expectedStatus {
t.Errorf("expected status code %d, got %d", cfg.expectedStatus, mockResp.Code)
}

}
func errorIfNotCached(t *testing.T, mockResp *httptest.ResponseRecorder, cfg chechCachedConfig) {
t.Helper()
if mockResp.Header().Get("x-cached-response") != "true" {
t.Errorf("path %s must be cached", cfg.reqPath)
}
if mockResp.Header().Get("location") != cfg.expectedURL {
t.Errorf("expected location data %s, got %s", cfg.expectedURL, mockResp.Header().Get("location"))
}
if mockResp.Code != cfg.expectedStatus {
t.Errorf("expected status code %d, got %d", cfg.expectedStatus, mockResp.Code)
}
}
func TestCacheInterceptor_DontCachePaths(t *testing.T) {
interceptor := newTestCacheInterceptor(t, 20*time.Second)
paths := []string{
"/metrics",
"/health",
"/monitor",
}
testURL := "https://foor-bar.example.com/abc?foo=bar&bar=foo"
testStatus := http.StatusAccepted
for _, path := range paths {

mockResp := genDoublePassInterceptorResult(t, passInterceptorConfig{
interceptor: &interceptor,
reqPath: path,
reqMethod: "GET",
expectedURL: testURL,
expectedStatus: testStatus,
})

errorIfCached(t, mockResp, chechCachedConfig{
reqPath: path,
expectedURL: testURL,
expectedStatus: testStatus,
})
}
}

func TestCacheInterceptor_DontCacheReqMethods(t *testing.T) {
interceptor := newTestCacheInterceptor(t, 20*time.Second)
methods := []string{
"OPTIONS",
"POST",
}
path := "/test"
testURL := "https://foor-bar.example.com/abc?foo=bar&bar=foo"
testStatus := http.StatusOK

for _, m := range methods {

mockResp := genDoublePassInterceptorResult(t, passInterceptorConfig{
interceptor: &interceptor,
reqPath: path,
reqMethod: m,
expectedURL: testURL,
expectedStatus: testStatus,
})

errorIfCached(t, mockResp, chechCachedConfig{
reqPath: path,
expectedURL: testURL,
expectedStatus: testStatus,
})
}

}

func TestCacheInterceptor_CacheInvalidation(t *testing.T) {
testURL := "https://foor-bar.example.com/abc?foo=bar&bar=foo"
newURL := "https://new-foo-bar.example.com/new?new=true"
expectedStatus := http.StatusFound
path := "/test"
t.Run("DELETE method cache invalidation", func(t *testing.T) {
interceptor := newTestCacheInterceptor(t, 20*time.Second)

cachedResp := genDoublePassInterceptorResult(t, passInterceptorConfig{
interceptor: &interceptor,
reqPath: path,
reqMethod: "GET",
expectedURL: testURL,
expectedStatus: expectedStatus,
})
errorIfNotCached(t, cachedResp, chechCachedConfig{
reqPath: path,
expectedURL: testURL,
expectedStatus: expectedStatus,
})
invalidatedResp := genSinglePassInterceptorResult(t, passInterceptorConfig{
interceptor: &interceptor,
reqPath: path,
reqMethod: "DELETE",
expectedURL: newURL,
expectedStatus: expectedStatus,
})
errorIfCached(t, invalidatedResp, chechCachedConfig{
reqPath: path,
expectedURL: newURL,
expectedStatus: expectedStatus,
})
freshResp := genSinglePassInterceptorResult(t, passInterceptorConfig{
interceptor: &interceptor,
reqPath: path,
reqMethod: "GET",
expectedURL: newURL,
expectedStatus: expectedStatus,
})
errorIfCached(t, freshResp, chechCachedConfig{
reqPath: path,
expectedURL: newURL,
expectedStatus: expectedStatus,
})
})
t.Run("PUT method cache invalidation", func(t *testing.T) {
interceptor := newTestCacheInterceptor(t, 20*time.Second)
cachedResp := genDoublePassInterceptorResult(t, passInterceptorConfig{
interceptor: &interceptor,
reqPath: path,
reqMethod: "GET",
expectedURL: testURL,
expectedStatus: expectedStatus,
})
errorIfNotCached(t, cachedResp, chechCachedConfig{
reqPath: path,
expectedURL: testURL,
expectedStatus: expectedStatus,
})
invalidatedResp := genSinglePassInterceptorResult(t, passInterceptorConfig{
interceptor: &interceptor,
reqPath: path,
reqMethod: "PUT",
expectedURL: newURL,
expectedStatus: expectedStatus,
})
errorIfCached(t, invalidatedResp, chechCachedConfig{
reqPath: path,
expectedURL: newURL,
expectedStatus: expectedStatus,
})
freshResp := genSinglePassInterceptorResult(t, passInterceptorConfig{
interceptor: &interceptor,
reqPath: path,
reqMethod: "GET",
expectedURL: newURL,
expectedStatus: expectedStatus,
})
errorIfCached(t, freshResp, chechCachedConfig{
reqPath: path,
expectedURL: newURL,
expectedStatus: expectedStatus,
})
})

}
func TestCacheInterceptor_DontCacheNotFound(t *testing.T) {
interceptor := newTestCacheInterceptor(t, 20*time.Second)
testURL := "https://foor-bar.example.com/abc?foo=bar&bar=foo"
expectedStatus := http.StatusNotFound
path := "/test"

notFoundResp := genDoublePassInterceptorResult(t, passInterceptorConfig{
interceptor: &interceptor,
reqPath: path,
reqMethod: "GET",
expectedURL: testURL,
expectedStatus: expectedStatus,
})
errorIfCached(t, notFoundResp, chechCachedConfig{
reqPath: path,
expectedURL: testURL,
expectedStatus: expectedStatus,
})
}
func TestCacheInterceptor_TTLInvalidation(t *testing.T) {
ttl := 50 * time.Millisecond
interceptor := newTestCacheInterceptor(t, ttl)
testURL := "https://foor-bar.example.com/abc?foo=bar&bar=foo"
expectedStatus := http.StatusFound
path := "/test"

cachedResp := genDoublePassInterceptorResult(t, passInterceptorConfig{
interceptor: &interceptor,
reqPath: path,
reqMethod: "GET",
expectedURL: testURL,
expectedStatus: expectedStatus,
})
errorIfNotCached(t, cachedResp, chechCachedConfig{
reqPath: path,
expectedURL: testURL,
expectedStatus: expectedStatus,
})
time.Sleep(ttl * 2)
expiredCacheResp := genSinglePassInterceptorResult(t, passInterceptorConfig{
interceptor: &interceptor,
reqPath: path,
reqMethod: "GET",
expectedURL: testURL,
expectedStatus: expectedStatus,
})

errorIfCached(t, expiredCacheResp, chechCachedConfig{
reqPath: path,
expectedURL: testURL,
expectedStatus: expectedStatus,
})
}

0 comments on commit 719cd7a

Please sign in to comment.