Skip to content
Draft
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
102 changes: 102 additions & 0 deletions http/middleware.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package http

import (
"bytes"
"context"
"maps"
"net/http"
"time"

"github.com/ksysoev/anycache"
)

type responseWriter struct {
header http.Header
body bytes.Buffer
statusCode int
hasWritten bool
}

type cachedResponse struct {
Header http.Header `json:"header"`
Body []byte `json:"body"`
StatusCode int `json:"status_code"`
}

func newResponseWriter() *responseWriter {
return &responseWriter{
header: make(http.Header),
}
}

func (rw *responseWriter) Header() http.Header {
return rw.header
}

func (rw *responseWriter) WriteHeader(statusCode int) {
if rw.hasWritten {
return
}

rw.statusCode = statusCode
rw.hasWritten = true
}

func (rw *responseWriter) Write(data []byte) (int, error) {
if !rw.hasWritten {
rw.WriteHeader(http.StatusOK)
}

return rw.body.Write(data)
}

func NewMiddleware(c *anycache.Cache) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key := getRequestKey(r)

if key == "" {
// bypass caching for non-GET requests
next.ServeHTTP(w, r)
return
}

var res cachedResponse

err := c.CacheStruct(r.Context(), key, time.Minute, func(ctx context.Context) (any, error) {
resWriter := newResponseWriter()
next.ServeHTTP(resWriter, r)

statusCode := resWriter.statusCode
if statusCode == 0 {
statusCode = http.StatusOK
}

return cachedResponse{
Header: resWriter.header,
StatusCode: statusCode,
Body: resWriter.body.Bytes(),
}, nil
}, &res)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}

maps.Copy(w.Header(), res.Header)
w.WriteHeader(res.StatusCode)

if len(res.Body) != 0 {
_, _ = w.Write(res.Body)
}
})
}
}

func getRequestKey(req *http.Request) string {
if req.Method != http.MethodGet {
return ""
}

return req.URL.Path
}
116 changes: 116 additions & 0 deletions http/middleware_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package http

import (
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"

"github.com/ksysoev/anycache"
"github.com/ksysoev/anycache/storage/inmemory"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func newTestMiddleware(t *testing.T) func(http.Handler) http.Handler {
t.Helper()

store, err := inmemory.New(16)
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, store.Close())
})

return NewMiddleware(anycache.New(store))
}

func TestGetRequestKey(t *testing.T) {
tests := []struct {
name string
method string
path string
want string
}{
{name: "get request uses path", method: http.MethodGet, path: "/users/42", want: "/users/42"},
{name: "post request bypasses cache", method: http.MethodPost, path: "/users/42", want: ""},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(tt.method, tt.path+"?q=1", http.NoBody)
assert.Equal(t, tt.want, getRequestKey(req))
})
}
}

func TestMiddlewareCachesGetResponses(t *testing.T) {
middleware := newTestMiddleware(t)

var calls atomic.Int32

handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
call := calls.Add(1)

w.Header().Set("X-Cacheable", "yes")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte("response-" + string('0'+call)))
}))

first := httptest.NewRecorder()
handler.ServeHTTP(first, httptest.NewRequest(http.MethodGet, "/articles", http.NoBody))

assert.Equal(t, http.StatusCreated, first.Code)
assert.Equal(t, "yes", first.Header().Get("X-Cacheable"))
assert.Equal(t, "response-1", first.Body.String())

second := httptest.NewRecorder()
handler.ServeHTTP(second, httptest.NewRequest(http.MethodGet, "/articles", http.NoBody))

assert.Equal(t, int32(1), calls.Load())
assert.Equal(t, http.StatusCreated, second.Code)
assert.Equal(t, "yes", second.Header().Get("X-Cacheable"))
assert.Equal(t, "response-1", second.Body.String())
}

func TestMiddlewareBypassesNonGetRequests(t *testing.T) {
middleware := newTestMiddleware(t)

var calls atomic.Int32

handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
call := calls.Add(1)
_, _ = w.Write([]byte("response-" + string('0'+call)))
}))

first := httptest.NewRecorder()
handler.ServeHTTP(first, httptest.NewRequest(http.MethodPost, "/articles", http.NoBody))

second := httptest.NewRecorder()
handler.ServeHTTP(second, httptest.NewRequest(http.MethodPost, "/articles", http.NoBody))

assert.Equal(t, int32(2), calls.Load())
assert.Equal(t, "response-1", first.Body.String())
assert.Equal(t, "response-2", second.Body.String())
}

func TestMiddlewareDefaultsEmptyResponseToStatusOK(t *testing.T) {
middleware := newTestMiddleware(t)

var calls atomic.Int32

handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
}))

first := httptest.NewRecorder()
handler.ServeHTTP(first, httptest.NewRequest(http.MethodGet, "/empty", http.NoBody))

second := httptest.NewRecorder()
handler.ServeHTTP(second, httptest.NewRequest(http.MethodGet, "/empty", http.NoBody))

assert.Equal(t, int32(1), calls.Load())
assert.Equal(t, http.StatusOK, first.Code)
assert.Equal(t, http.StatusOK, second.Code)
assert.Empty(t, first.Body.String())
assert.Empty(t, second.Body.String())
}