-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Implement cache interceptor middleware
* test: Add tests of cache interceptor using httptest package * refactor: Decrease test ttl time for memcache_test
- Loading branch information
1 parent
f0b6749
commit 719cd7a
Showing
3 changed files
with
350 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
272
internal/adapters/rest/middleware/cache_interceptor_test.go
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}) | ||
} |