Skip to content

Commit a00fa59

Browse files
Support HTTP POST requests (#44)
* allow caching of other HTTP methods like POST * add contentlength and bodyhash key template vars For caching POST requests it's important to distinguish requests between different body contents. This commit adds `http.request.contentlength` and `http.request.bodyhash`. For the body hash it's important that the cache key is calculated before the body has been read (before it's empty). Therefore the key is passed to `fetchUpstream`.
1 parent b904cb7 commit a00fa59

File tree

7 files changed

+121
-10
lines changed

7 files changed

+121
-10
lines changed

cache.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@ package httpcache
33
import (
44
"bytes"
55
"context"
6+
"crypto/sha1"
67
"encoding/json"
78
"fmt"
89
"hash/crc32"
910
"io"
11+
"io/ioutil"
1012
"math"
1113
"net/http"
1214
"net/url"
@@ -431,9 +433,38 @@ func (h *HTTPCache) getBucketIndexForKey(key string) uint32 {
431433
// In caddy2, it is automatically add the map by addHTTPVarsToReplacer
432434
func getKey(cacheKeyTemplate string, r *http.Request) string {
433435
repl := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer)
436+
437+
// Add contentlength and bodyhash when not added before
438+
if _, ok := repl.Get("http.request.contentlength"); !ok {
439+
repl.Set("http.request.contentlength", r.ContentLength)
440+
repl.Map(func(key string) (interface{}, bool) {
441+
if key == "http.request.bodyhash" {
442+
return bodyHash(r), true
443+
}
444+
return nil, false
445+
})
446+
}
447+
434448
return repl.ReplaceKnown(cacheKeyTemplate, "")
435449
}
436450

451+
// bodyHash calculates a hash value of the request body
452+
func bodyHash(r *http.Request) string {
453+
body, err := ioutil.ReadAll(r.Body)
454+
if err != nil {
455+
return ""
456+
}
457+
458+
h := sha1.New()
459+
h.Write(body)
460+
bs := h.Sum(nil)
461+
result := fmt.Sprintf("%x", bs)
462+
463+
r.Body = ioutil.NopCloser(bytes.NewBuffer(body))
464+
465+
return result
466+
}
467+
437468
// Get returns the cached response
438469
func (h *HTTPCache) Get(key string, request *http.Request, includeStale bool) (*Entry, bool) {
439470
b := h.getBucketIndexForKey(key)

cache_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
package httpcache
22

33
import (
4+
"bytes"
5+
"context"
46
"io/ioutil"
57
"net/http"
68
"net/http/httptest"
79
"net/url"
810
"testing"
911
"time"
1012

13+
"github.com/caddyserver/caddy/v2"
14+
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
1115
"github.com/sillygod/cdp-cache/backends"
1216
"github.com/stretchr/testify/suite"
1317
)
@@ -271,9 +275,32 @@ func (suite *HTTPCacheTestSuite) TearDownSuite() {
271275
suite.Nil(err)
272276
}
273277

278+
type KeyTestSuite struct {
279+
suite.Suite
280+
}
281+
282+
func (suite *KeyTestSuite) TestContentLengthInKey() {
283+
body := []byte(`{"search":"my search string"}`)
284+
req := httptest.NewRequest("POST", "/", bytes.NewBuffer(body))
285+
ctx := context.WithValue(req.Context(), caddy.ReplacerCtxKey, caddyhttp.NewTestReplacer(req))
286+
req = req.WithContext(ctx)
287+
key := getKey("{http.request.contentlength}", req)
288+
suite.Equal("29", key)
289+
}
290+
291+
func (suite *KeyTestSuite) TestBodyHashInKey() {
292+
body := []byte(`{"search":"my search string"}`)
293+
req := httptest.NewRequest("POST", "/", bytes.NewBuffer(body))
294+
ctx := context.WithValue(req.Context(), caddy.ReplacerCtxKey, caddyhttp.NewTestReplacer(req))
295+
req = req.WithContext(ctx)
296+
key := getKey("{http.request.bodyhash}", req)
297+
suite.Equal("5edeb27ddae03685d04df2ab56ebf11fb9c8a711", key)
298+
}
299+
274300
func TestCacheStatusTestSuite(t *testing.T) {
275301
suite.Run(t, new(CacheStatusTestSuite))
276302
suite.Run(t, new(HTTPCacheTestSuite))
277303
suite.Run(t, new(RuleMatcherTestSuite))
278304
suite.Run(t, new(EntryTestSuite))
305+
suite.Run(t, new(KeyTestSuite))
279306
}

caddyfile.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ var (
3939
defaultLockTimeout = time.Duration(5) * time.Minute
4040
defaultMaxAge = time.Duration(5) * time.Minute
4141
defaultPath = "/tmp/caddy_cache"
42+
defaultMatchMethods = []string{"GET", "HEAD"}
4243
defaultCacheType = file
4344
defaultcacheBucketsNum = 256
4445
defaultCacheMaxMemorySize = GB // default is 1 GB
@@ -56,6 +57,7 @@ const (
5657
keyPath = "path"
5758
keyMatchHeader = "match_header"
5859
keyMatchPath = "match_path"
60+
keyMatchMethod = "match_methods"
5961
keyCacheKey = "cache_key"
6062
keyCacheBucketsNum = "cache_bucket_num"
6163
keyCacheMaxMemorySize = "cache_max_memory_size"
@@ -82,6 +84,7 @@ type Config struct {
8284
LockTimeout time.Duration `json:"lock_timeout,omitempty"`
8385
RuleMatchersRaws []RuleMatcherRawWithType `json:"rule_matcher_raws,omitempty"`
8486
RuleMatchers []RuleMatcher `json:"-"`
87+
MatchMethods []string `json:"match_methods,omitempty"`
8588
CacheBucketsNum int `json:"cache_buckets_num,omitempty"`
8689
CacheMaxMemorySize int `json:"cache_max_memory_size,omitempty"`
8790
Path string `json:"path,omitempty"`
@@ -97,6 +100,7 @@ func getDefaultConfig() *Config {
97100
LockTimeout: defaultLockTimeout,
98101
RuleMatchersRaws: []RuleMatcherRawWithType{},
99102
RuleMatchers: []RuleMatcher{},
103+
MatchMethods: defaultMatchMethods,
100104
CacheBucketsNum: defaultcacheBucketsNum,
101105
CacheMaxMemorySize: defaultCacheMaxMemorySize,
102106
Path: defaultPath,
@@ -215,6 +219,12 @@ func (h *Handler) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
215219
Data: data,
216220
})
217221

222+
case keyMatchMethod:
223+
if len(args) < 2 {
224+
return d.Err("Invalid usage of match_method in cache config.")
225+
}
226+
config.MatchMethods = append(config.MatchMethods, args...)
227+
218228
case keyCacheKey:
219229
if len(args) != 1 {
220230
return d.Err(fmt.Sprintf("Invalid usage of %s in cache config.", keyCacheKey))

handler.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ func popOrNil(h *Handler, errChan chan error) (err error) {
9393

9494
}
9595

96-
func (h *Handler) fetchUpstream(req *http.Request, next caddyhttp.Handler) (*Entry, error) {
96+
func (h *Handler) fetchUpstream(req *http.Request, next caddyhttp.Handler, key string) (*Entry, error) {
9797
// Create a new empty response
9898
response := NewResponse()
9999

@@ -131,7 +131,7 @@ func (h *Handler) fetchUpstream(req *http.Request, next caddyhttp.Handler) (*Ent
131131
response.WaitHeaders()
132132

133133
// Create a new CacheEntry
134-
return NewEntry(getKey(h.Config.CacheKeyTemplate, req), req, response, h.Config), popOrNil(h, errChan)
134+
return NewEntry(key, req, response, h.Config), popOrNil(h, errChan)
135135
}
136136

137137
// CaddyModule returns the Caddy module information
@@ -329,7 +329,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyht
329329

330330
}(h, start)
331331

332-
if !shouldUseCache(r) {
332+
if !shouldUseCache(r, h.Config) {
333333
h.addStatusHeaderIfConfigured(w, cacheBypass)
334334
return next.ServeHTTP(w, r)
335335
}
@@ -359,7 +359,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyht
359359
if h.Distributed != nil {
360360
// new an entry without fetching the upstream
361361
response := NewResponse()
362-
entry := NewEntry(getKey(h.Config.CacheKeyTemplate, r), r, response, h.Config)
362+
entry := NewEntry(key, r, response, h.Config)
363363
err := entry.setBackend(r.Context(), h.Config)
364364
if err != nil {
365365
return caddyhttp.Error(http.StatusInternalServerError, err)
@@ -393,7 +393,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyht
393393
// It should be fetched from upstream and save it in cache
394394

395395
t := time.Now()
396-
entry, err := h.fetchUpstream(r, next)
396+
entry, err := h.fetchUpstream(r, next, key)
397397
upstreamDuration = time.Since(t)
398398

399399
if entry.Response.Code >= 500 {

handler_test.go

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,13 @@ func (suite *HandlerProvisionTestSuite) TestProvisionRedisBackend() {
9696

9797
type DetermineShouldCacheTestSuite struct {
9898
suite.Suite
99+
Config *Config
100+
}
101+
102+
func (suite *DetermineShouldCacheTestSuite) SetupSuite() {
103+
if suite.Config == nil {
104+
suite.Config = getDefaultConfig()
105+
}
99106
}
100107

101108
func (suite *DetermineShouldCacheTestSuite) TestWebsocketConnection() {
@@ -119,20 +126,45 @@ func (suite *DetermineShouldCacheTestSuite) TestWebsocketConnection() {
119126

120127
for _, test := range tests {
121128
req := makeRequest("/", test.header)
122-
shouldBeCached := shouldUseCache(req)
129+
shouldBeCached := shouldUseCache(req, suite.Config)
123130
suite.Equal(test.shouldBeCached, shouldBeCached)
124131
}
125132

126133
}
127134

128135
func (suite *DetermineShouldCacheTestSuite) TestNonGETOrHeadMethod() {
129136
r := httptest.NewRequest("POST", "/", nil)
130-
shouldBeCached := shouldUseCache(r)
137+
shouldBeCached := shouldUseCache(r, suite.Config)
138+
suite.False(shouldBeCached)
139+
}
140+
141+
type DetermineShouldCachePOSTOnlyTestSuite struct {
142+
suite.Suite
143+
Config *Config
144+
}
145+
146+
func (suite *DetermineShouldCachePOSTOnlyTestSuite) SetupSuite() {
147+
if suite.Config == nil {
148+
suite.Config = getDefaultConfig()
149+
suite.Config.MatchMethods = []string{"POST"}
150+
}
151+
}
152+
153+
func (suite *DetermineShouldCachePOSTOnlyTestSuite) TestPOSTMethod() {
154+
r := httptest.NewRequest("POST", "/", nil)
155+
shouldBeCached := shouldUseCache(r, suite.Config)
156+
suite.True(shouldBeCached)
157+
}
158+
159+
func (suite *DetermineShouldCachePOSTOnlyTestSuite) TestGETMethod() {
160+
r := httptest.NewRequest("GET", "/", nil)
161+
shouldBeCached := shouldUseCache(r, suite.Config)
131162
suite.False(shouldBeCached)
132163
}
133164

134165
func TestCacheKeyTemplatingTestSuite(t *testing.T) {
135166
suite.Run(t, new(CacheKeyTemplatingTestSuite))
136167
suite.Run(t, new(DetermineShouldCacheTestSuite))
168+
suite.Run(t, new(DetermineShouldCachePOSTOnlyTestSuite))
137169
suite.Run(t, new(HandlerProvisionTestSuite))
138170
}

readme.org

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,11 @@
119119
*** match_path
120120
Only the request's path match the condition will be cached. Ex. =/= means all request need to be cached because all request's path must start with =/=
121121

122+
*** match_methods
123+
By default, only =GET= and =POST= methods are cached. If you would like to cache other methods as well you can configure here which methods should be cached, e.g.: =GET HEAD POST=.
124+
125+
To be able to distinguish different POST requests, it is advisable to include the body hash in the cache key, e.g.: ={http.request.method} {http.request.host}{http.request.uri.path}?{http.request.uri.query} {http.request.contentlength} {http.request.bodyhash}=
126+
122127
*** default_max_age
123128
The cache's expiration time.
124129

response.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -144,10 +144,16 @@ func (r *Response) WriteHeader(code int) {
144144
r.headersChan <- struct{}{}
145145
}
146146

147-
func shouldUseCache(req *http.Request) bool {
147+
func shouldUseCache(req *http.Request, config *Config) bool {
148148

149-
if req.Method != "GET" && req.Method != "HEAD" {
150-
// Only cache Get and head request
149+
matchMethod := false
150+
for _, method := range config.MatchMethods {
151+
if method == req.Method {
152+
matchMethod = true
153+
break
154+
}
155+
}
156+
if !matchMethod {
151157
return false
152158
}
153159

0 commit comments

Comments
 (0)