Skip to content

Commit

Permalink
Feature/1838 cache post requests (#2114)
Browse files Browse the repository at this point in the history
* Add support for caching of POST request
When an endpoint is added in cache list, caching will be enabled to for all safe methods as well as POST request.

* Add test for per path caching

* Use hash of POST body while creating checksum for cache key

* 1. Add new advance_cache_config field in api definition
2. Add support of defining regex pattern to calculate cache key from post body

* Add test for caching of POST request

* Add delay in TestCachePostRequest test

* Minor fixes

* Add test cases to check cache works correctly with regex
  • Loading branch information
komalsukhani authored and joshblakeley committed Apr 5, 2019
1 parent 42bfb74 commit b363c8b
Show file tree
Hide file tree
Showing 4 changed files with 130 additions and 16 deletions.
32 changes: 28 additions & 4 deletions api_definition.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ import (
"github.com/TykTechnologies/tyk/storage"
)

//const used by cache middleware
const SAFE_METHODS = "SAFE_METHODS"

const (
LDAPStorageEngine apidef.StorageEngineCode = "ldap"
RPCStorageEngine apidef.StorageEngineCode = "rpc"
Expand Down Expand Up @@ -110,6 +113,7 @@ type URLSpec struct {
Spec *regexp.Regexp
Status URLStatus
MethodActions map[string]apidef.EndpointMethodMeta
CacheConfig EndPointCacheMeta
TransformAction TransformSpec
TransformResponseAction TransformSpec
TransformJQAction TransformJQSpec
Expand All @@ -128,6 +132,11 @@ type URLSpec struct {
Internal apidef.InternalMeta
}

type EndPointCacheMeta struct {
Method string
CacheKeyRegex string
}

type TransformSpec struct {
apidef.TemplateMeta
Template *template.Template
Expand Down Expand Up @@ -492,14 +501,25 @@ func (a APIDefinitionLoader) compileExtendedPathSpec(paths []apidef.EndPointMeta
return urlSpec
}

func (a APIDefinitionLoader) compileCachedPathSpec(paths []string) []URLSpec {
func (a APIDefinitionLoader) compileCachedPathSpec(oldpaths []string, newpaths []apidef.CacheMeta) []URLSpec {
// transform an extended configuration URL into an array of URLSpecs
// This way we can iterate the whole array once, on match we break with status
urlSpec := []URLSpec{}

for _, stringSpec := range paths {
for _, stringSpec := range oldpaths {
newSpec := URLSpec{}
a.generateRegex(stringSpec, &newSpec, Cached)
newSpec.CacheConfig.Method = SAFE_METHODS
newSpec.CacheConfig.CacheKeyRegex = ""
// Extend with method actions
urlSpec = append(urlSpec, newSpec)
}

for _, spec := range newpaths {
newSpec := URLSpec{}
a.generateRegex(spec.Path, &newSpec, Cached)
newSpec.CacheConfig.Method = spec.Method
newSpec.CacheConfig.CacheKeyRegex = spec.CacheKeyRegex
// Extend with method actions
urlSpec = append(urlSpec, newSpec)
}
Expand Down Expand Up @@ -822,7 +842,7 @@ func (a APIDefinitionLoader) getExtendedPathSpecs(apiVersionDef apidef.VersionIn
ignoredPaths := a.compileExtendedPathSpec(apiVersionDef.ExtendedPaths.Ignored, Ignored)
blackListPaths := a.compileExtendedPathSpec(apiVersionDef.ExtendedPaths.BlackList, BlackList)
whiteListPaths := a.compileExtendedPathSpec(apiVersionDef.ExtendedPaths.WhiteList, WhiteList)
cachedPaths := a.compileCachedPathSpec(apiVersionDef.ExtendedPaths.Cached)
cachedPaths := a.compileCachedPathSpec(apiVersionDef.ExtendedPaths.Cached, apiVersionDef.ExtendedPaths.AdvanceCacheConfig)
transformPaths := a.compileTransformPathSpec(apiVersionDef.ExtendedPaths.Transform, Transformed)
transformResponsePaths := a.compileTransformPathSpec(apiVersionDef.ExtendedPaths.TransformResponse, TransformedResponse)
transformJQPaths := a.compileTransformJQPathSpec(apiVersionDef.ExtendedPaths.TransformJQ, TransformedJQ)
Expand Down Expand Up @@ -1026,8 +1046,12 @@ func (a *APISpec) CheckSpecMatchesStatus(r *http.Request, rxPaths []URLSpec, mod
}

switch v.Status {
case Ignored, BlackList, WhiteList, Cached:
case Ignored, BlackList, WhiteList:
return true, nil
case Cached:
if method == v.CacheConfig.Method || (v.CacheConfig.Method == SAFE_METHODS && (method == "GET" || method == "HEADERS" || method == "OPTIONS")) {
return true, &v.CacheConfig
}
case Transformed:
if method == v.TransformAction.Method {
return true, &v.TransformAction
Expand Down
7 changes: 7 additions & 0 deletions apidef/api_definitions.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@ type EndPointMeta struct {
MethodActions map[string]EndpointMethodMeta `bson:"method_actions" json:"method_actions"`
}

type CacheMeta struct {
Method string `bson:"method" json:"method"`
Path string `bson:"path" json:"path"`
CacheKeyRegex string `bson:"cache_key_regex" json:"cache_key_regex"`
}

type RequestInputType string

type TemplateData struct {
Expand Down Expand Up @@ -199,6 +205,7 @@ type ExtendedPathsSet struct {
WhiteList []EndPointMeta `bson:"white_list" json:"white_list,omitempty"`
BlackList []EndPointMeta `bson:"black_list" json:"black_list,omitempty"`
Cached []string `bson:"cache" json:"cache,omitempty"`
AdvanceCacheConfig []CacheMeta `bson:"advance_cache_config" json:"advance_cache_config,omitempty"`
Transform []TemplateMeta `bson:"transform" json:"transform,omitempty"`
TransformResponse []TemplateMeta `bson:"transform_response" json:"transform_response,omitempty"`
TransformJQ []TransformJQMeta `bson:"transform_jq" json:"transform_jq,omitempty"`
Expand Down
37 changes: 37 additions & 0 deletions gateway_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1032,6 +1032,43 @@ func TestCacheAllSafeRequests(t *testing.T) {
}...)
}

func TestCachePostRequest(t *testing.T) {
ts := newTykTestServer()
defer ts.Close()
cache := storage.RedisCluster{KeyPrefix: "cache-"}
defer cache.DeleteScanMatch("*")

buildAndLoadAPI(func(spec *APISpec) {
spec.CacheOptions = apidef.CacheOptions{
CacheTimeout: 120,
EnableCache: true,
CacheAllSafeRequests: false,
}

updateAPIVersion(spec, "v1", func(v *apidef.VersionInfo) {
json.Unmarshal([]byte(`[{
"method":"POST",
"path":"/",
"cache_key_regex":"\"id\":[^,]*"
}
]`), &v.ExtendedPaths.AdvanceCacheConfig)
})

spec.Proxy.ListenPath = "/"
})

headerCache := map[string]string{"x-tyk-cached-response": "1"}

ts.Run(t, []test.TestCase{
{Method: "POST", Path: "/", Data: "{\"id\":\"1\",\"name\":\"test\"}", HeadersNotMatch: headerCache, Delay: 10 * time.Millisecond},
{Method: "POST", Path: "/", Data: "{\"id\":\"1\",\"name\":\"test\"}", HeadersMatch: headerCache, Delay: 10 * time.Millisecond},
{Method: "POST", Path: "/", Data: "{\"id\":\"2\",\"name\":\"test\"}", HeadersNotMatch: headerCache, Delay: 10 * time.Millisecond},
// if regex match returns nil, then request body is ignored while generating cache key
{Method: "POST", Path: "/", Data: "{\"name\":\"test\"}", HeadersNotMatch: headerCache, Delay: 10 * time.Millisecond},
{Method: "POST", Path: "/", Data: "{\"name\":\"test2\"}", HeadersMatch: headerCache, Delay: 10 * time.Millisecond},
}...)
}

func TestCacheEtag(t *testing.T) {
ts := newTykTestServer()
defer ts.Close()
Expand Down
70 changes: 58 additions & 12 deletions mw_redis_cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,14 @@ import (
"encoding/hex"
"errors"
"io"
"io/ioutil"
"net/http"
"strconv"
"strings"
"time"

"github.com/TykTechnologies/murmur3"
"github.com/TykTechnologies/tyk/regexp"
"github.com/TykTechnologies/tyk/request"
"github.com/TykTechnologies/tyk/storage"
)
Expand Down Expand Up @@ -41,13 +44,44 @@ func (m *RedisCacheMiddleware) EnabledForSpec() bool {
return m.Spec.CacheOptions.EnableCache
}

func (m *RedisCacheMiddleware) CreateCheckSum(req *http.Request, keyName string) string {
func (m *RedisCacheMiddleware) CreateCheckSum(req *http.Request, keyName string, regex string) (string, error) {
h := md5.New()
io.WriteString(h, req.Method)
io.WriteString(h, "-")
io.WriteString(h, req.URL.String())
if req.Method == http.MethodPost {
if req.Body != nil {
bodyBytes, err := ioutil.ReadAll(req.Body)

if err != nil {
return "", err
}

defer req.Body.Close()
req.Body = ioutil.NopCloser(bytes.NewBuffer(bodyBytes))

m := murmur3.New128()
if regex == "" {
io.WriteString(h, "-")
m.Write(bodyBytes)
io.WriteString(h, hex.EncodeToString(m.Sum(nil)))
} else {
r, err := regexp.Compile(regex)
if err != nil {
return "", err
}
match := r.Find(bodyBytes)
if match != nil {
io.WriteString(h, "-")
m.Write(match)
io.WriteString(h, hex.EncodeToString(m.Sum(nil)))
}
}
}
}

reqChecksum := hex.EncodeToString(h.Sum(nil))
return m.Spec.APIID + keyName + reqChecksum
return m.Spec.APIID + keyName + reqChecksum, nil
}

func (m *RedisCacheMiddleware) getTimeTTL(cacheTTL int64) string {
Expand Down Expand Up @@ -99,25 +133,28 @@ func (m *RedisCacheMiddleware) decodePayload(payload string) (string, string, er

// ProcessRequest will run any checks on the request on the way through the system, return an error to have the chain fail
func (m *RedisCacheMiddleware) ProcessRequest(w http.ResponseWriter, r *http.Request, _ interface{}) (error, int) {

// Only allow idempotent (safe) methods
if r.Method != "GET" && r.Method != "HEAD" && r.Method != "OPTIONS" {
if r.Method != "GET" && r.Method != "HEAD" && r.Method != "OPTIONS" && r.Method != "POST" {
return nil, http.StatusOK
}

var stat RequestStatus
var cacheKeyRegex string

_, versionPaths, _, _ := m.Spec.Version(r)
isVirtual, _ := m.Spec.CheckSpecMatchesStatus(r, versionPaths, VirtualPath)

// Lets see if we can throw a sledgehammer at this
if m.Spec.CacheOptions.CacheAllSafeRequests {
if m.Spec.CacheOptions.CacheAllSafeRequests && r.Method != "POST" {
stat = StatusCached
} else {
}
if stat != StatusCached {
// New request checker, more targeted, less likely to fail
found, _ := m.Spec.CheckSpecMatchesStatus(r, versionPaths, Cached)
found, meta := m.Spec.CheckSpecMatchesStatus(r, versionPaths, Cached)
if found {
cacheMeta := meta.(*EndPointCacheMeta)
stat = StatusCached
cacheKeyRegex = cacheMeta.CacheKeyRegex
}
}

Expand All @@ -132,10 +169,20 @@ func (m *RedisCacheMiddleware) ProcessRequest(w http.ResponseWriter, r *http.Req
token = request.RealIP(r)
}

key := m.CreateCheckSum(r, token)
retBlob, err := m.CacheStore.GetKey(key)
var errCreatingChecksum bool
var retBlob string
key, err := m.CreateCheckSum(r, token, cacheKeyRegex)
if err != nil {
log.Debug("Error creating checksum. Skipping cache check")
errCreatingChecksum = true
} else {
retBlob, err = m.CacheStore.GetKey(key)
}

if err != nil {
log.Debug("Cache enabled, but record not found")
if !errCreatingChecksum {
log.Debug("Cache enabled, but record not found")
}
// Pass through to proxy AND CACHE RESULT

var reqVal *http.Response
Expand Down Expand Up @@ -199,15 +246,14 @@ func (m *RedisCacheMiddleware) ProcessRequest(w http.ResponseWriter, r *http.Req
}
}

if cacheThisRequest {
if cacheThisRequest && !errCreatingChecksum {
log.Debug("Caching request to redis")
var wireFormatReq bytes.Buffer
reqVal.Write(&wireFormatReq)
log.Debug("Cache TTL is:", cacheTTL)
ts := m.getTimeTTL(cacheTTL)
toStore := m.encodePayload(wireFormatReq.String(), ts)
go m.CacheStore.SetKey(key, toStore, cacheTTL)

}

return nil, mwStatusRespond
Expand Down

0 comments on commit b363c8b

Please sign in to comment.