diff --git a/internal/adapters/memcache/memcache_test.go b/internal/adapters/memcache/memcache_test.go index c614285..52f554b 100644 --- a/internal/adapters/memcache/memcache_test.go +++ b/internal/adapters/memcache/memcache_test.go @@ -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 { diff --git a/internal/adapters/rest/middleware/cache_interceptor.go b/internal/adapters/rest/middleware/cache_interceptor.go new file mode 100644 index 0000000..0a0a69e --- /dev/null +++ b/internal/adapters/rest/middleware/cache_interceptor.go @@ -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 + }() + }) +} diff --git a/internal/adapters/rest/middleware/cache_interceptor_test.go b/internal/adapters/rest/middleware/cache_interceptor_test.go new file mode 100644 index 0000000..54cee6f --- /dev/null +++ b/internal/adapters/rest/middleware/cache_interceptor_test.go @@ -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, + }) +}