pax_global_header00006660000000000000000000000064150664636070014527gustar00rootroot0000000000000052 comment=244e49bafa4f6c323e46795db1683a9620e18825 gohugoio-httpcache-244e49b/000077500000000000000000000000001506646360700156475ustar00rootroot00000000000000gohugoio-httpcache-244e49b/.github/000077500000000000000000000000001506646360700172075ustar00rootroot00000000000000gohugoio-httpcache-244e49b/.github/workflows/000077500000000000000000000000001506646360700212445ustar00rootroot00000000000000gohugoio-httpcache-244e49b/.github/workflows/test.yml000066400000000000000000000021511506646360700227450ustar00rootroot00000000000000on: push: branches: [master] pull_request: name: Test permissions: contents: read jobs: test: strategy: matrix: go-version: [1.21.x, 1.22.x] platform: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.platform }} steps: - name: Install Go uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 with: go-version: ${{ matrix.go-version }} - name: Install staticcheck run: go install honnef.co/go/tools/cmd/staticcheck@latest shell: bash - name: Update PATH run: echo "$(go env GOPATH)/bin" >> $GITHUB_PATH shell: bash - name: Checkout code uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 - name: Fmt if: matrix.platform != 'windows-latest' run: "diff <(gofmt -d .) <(printf '')" shell: bash - name: Vet run: go vet ./... - name: Staticcheck # Ubuntu latest go. if: strategy.job-index == 3 run: staticcheck ./... - name: Test run: go test -race ./... -coverpkg=./... gohugoio-httpcache-244e49b/LICENSE.txt000066400000000000000000000021731506646360700174750ustar00rootroot00000000000000Original work Copyright © 2012 Greg Jones (greg.jones@gmail.com) Modified work Copyright © 2024 The Hugo Authors. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.gohugoio-httpcache-244e49b/README.md000066400000000000000000000010641506646360700171270ustar00rootroot00000000000000[![Tests on Linux, MacOS and Windows](https://github.com/gohugoio/httpcache/workflows/Test/badge.svg)](https://github.com/gohugoio/httpcache/actions?query=workflow:Test) [![Go Report Card](https://goreportcard.com/badge/github.com/gohugoio/httpcache)](https://goreportcard.com/report/github.com/gohugoio/httpcache) [![GoDoc](https://godoc.org/github.com/gohugoio/httpcache?status.svg)](https://godoc.org/github.com/gohugoio/httpcache) This is a fork of [gregjones/httpcache](https://github.com/gregjones/httpcache). License ------- - [MIT License](LICENSE.txt) gohugoio-httpcache-244e49b/go.mod000066400000000000000000000004331506646360700167550ustar00rootroot00000000000000module github.com/gohugoio/httpcache go 1.20 require github.com/frankban/quicktest v1.14.6 require ( github.com/google/go-cmp v0.5.9 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/kr/text v0.2.0 // indirect github.com/rogpeppe/go-internal v1.9.0 // indirect ) gohugoio-httpcache-244e49b/go.sum000066400000000000000000000020131506646360700167760ustar00rootroot00000000000000github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= gohugoio-httpcache-244e49b/httpcache.go000066400000000000000000000454451506646360700201550ustar00rootroot00000000000000// Package httpcache provides a http.RoundTripper implementation that works as a // mostly RFC-compliant cache for http responses. // // It is only suitable for use as a 'private' cache (i.e. for a web-browser or an API-client // and not for a shared proxy). package httpcache import ( "bufio" "bytes" "crypto/md5" "encoding/hex" "errors" "hash" "io" "net/http" "net/http/httputil" "strings" "sync" "time" ) const ( stale = iota fresh transparent // XFromCache is the header added to responses that are returned from the cache XFromCache = "X-From-Cache" // xEtags is the prefix for the header with the custom etag pair set in the cached response. xEtags = "X-Etags-" // XETag1 is the key for the first eTag value. XETag1 = xEtags + "1" // XETag2 is the key for the second eTag value. // Note that in the cache, XETag1 and XETag2 will always be the same. // In the Response returned from Response, XETag1 will be the cached value (old) and // XETag2 will be the eTag value from the server (new). XETag2 = xEtags + "2" ) // A Cache interface is used by the Transport to store and retrieve responses. type Cache interface { // Get returns the []byte representation of a cached response and a bool // set to set to false if the key is not found or the value is stale. Get(key string) (responseBytes []byte, ok bool) // Set stores the []byte representation of a response against a key Set(key string, responseBytes []byte) // Delete removes the value associated with the key Delete(key string) } // cacheKey returns the cache key for req. func (t *Transport) cacheKey(req *http.Request) string { if t.CacheKey != nil { return t.CacheKey(req) } cacheable := (req.Method != http.MethodHead || req.Method == "HEAD") && req.Header.Get("range") == "" if !cacheable { return "" } if req.Method == http.MethodGet { return req.URL.String() } else { return req.Method + " " + req.URL.String() } } // cachedResponse returns the cached http.Response for req if present and // a bool set to false if the value is stale. func (t *Transport) cachedResponse(req *http.Request) (*http.Response, bool, error) { cachedVal, ok := t.Cache.Get(t.cacheKey(req)) if !ok && len(cachedVal) == 0 { return nil, false, nil } b := bytes.NewBuffer(cachedVal) resp, err := http.ReadResponse(bufio.NewReader(b), req) if err != nil { return nil, false, err } return resp, ok, nil } // Transport is an implementation of http.RoundTripper that will return values from a cache // where possible (avoiding a network request) and will additionally add validators (etag/if-modified-since) // to repeated requests allowing servers to return 304 / Not Modified type Transport struct { // The RoundTripper interface actually used to make requests // If nil, http.DefaultTransport is used Transport http.RoundTripper // The Cache interface used to store and retrieve responses. Cache Cache // If true, responses returned from the cache will be given an extra header, X-From-Cache MarkCachedResponses bool // if EnableETagPair is true, the Transport will store the pair of eTags in the response header. // These are stored in the X-Etags-1 and X-Etags-2 headers. // If these are different, the response has been modified. // If the server does not return an eTag, the MD5 hash of the response body is used. EnableETagPair bool // CacheKey is an optional func that returns the key to use to store the response. // An empty string signals that this request should not be cached. CacheKey func(req *http.Request) string // AlwaysUseCachedResponse is an optional func that when it returns true // a successful response from the cache will be returned without connecting to the server. AlwaysUseCachedResponse func(req *http.Request, key string) bool // ShouldCache is an optional func that when it returns false, the response will not be cached. ShouldCache func(req *http.Request, resp *http.Response, key string) bool // CanStore is an optional func that when set, is called to determine if a response // can be stored in the cache. // If not set, a default implementation is used that checks for 'no-store' in // the request and response cache-control headers. // // Note that this does not imply that the response will be cached, only that it is // allowed to be cached. The ShouldCache func is called after this to make the final decision. CanStore func(reqCacheControl, respCacheControl CacheControl) (canStore bool) // Around is an optional func. // If set, the Transport will call Around at the start of RoundTrip // and defer the returned func until the end of RoundTrip. // Typically used to implement a lock that is held for the duration of the RoundTrip. Around func(req *http.Request, key string) func() init sync.Once } func (t *Transport) doInit() { if t.Cache == nil { panic("no Cache set on Transport") } if t.CanStore == nil { t.CanStore = canStore } if t.ShouldCache == nil { t.ShouldCache = func(req *http.Request, resp *http.Response, key string) bool { return true } } } // varyMatches will return false unless all of the cached values for the headers listed in Vary // match the new request func varyMatches(cachedResp *http.Response, req *http.Request) bool { for _, header := range headerAllCommaSepValues(cachedResp.Header, "vary") { header = http.CanonicalHeaderKey(header) if header != "" && req.Header.Get(header) != cachedResp.Header.Get("X-Varied-"+header) { return false } } return true } // RoundTrip takes a Request and returns a Response // // If there is a fresh Response already in cache, then it will be returned without connecting to // the server. // // If there is a stale Response, then any validators it contains will be set on the new request // to give the server a chance to respond with NotModified. If this happens, then the cached Response // will be returned. func (t *Transport) RoundTrip(req *http.Request) (resp *http.Response, err error) { t.init.Do(func() { t.doInit() }) cacheKey := t.cacheKey(req) if f := t.Around; f != nil { defer f(req, cacheKey)() } var cachedXEtag string cacheable := cacheKey != "" var ( cachedResp *http.Response hasCachedResp bool ) if cacheable { cachedResp, hasCachedResp, err = t.cachedResponse(req) if err == nil && hasCachedResp && t.AlwaysUseCachedResponse != nil && t.AlwaysUseCachedResponse(req, cacheKey) { return cachedResp, nil } } else { // Need to invalidate an existing value t.Cache.Delete(cacheKey) } transport := t.Transport if transport == nil { transport = http.DefaultTransport } if cachedResp != nil { if t.EnableETagPair { cachedXEtag, _ = getXETags(cachedResp.Header) } } if cacheable && hasCachedResp && err == nil { if t.MarkCachedResponses { cachedResp.Header.Set(XFromCache, "1") } if varyMatches(cachedResp, req) { // Can only use cached value if the new request doesn't Vary significantly freshness := getFreshness(cachedResp.Header, req.Header) if freshness == fresh { return cachedResp, nil } if freshness == stale { var req2 *http.Request // Add validators if caller hasn't already done so etag := cachedResp.Header.Get("etag") if etag != "" && req.Header.Get("etag") == "" { req2 = cloneRequest(req) req2.Header.Set("if-none-match", etag) } lastModified := cachedResp.Header.Get("last-modified") if lastModified != "" && req.Header.Get("last-modified") == "" { if req2 == nil { req2 = cloneRequest(req) } req2.Header.Set("if-modified-since", lastModified) } if req2 != nil { req = req2 } } } resp, err = transport.RoundTrip(req) if err == nil && req.Method != http.MethodHead && resp.StatusCode == http.StatusNotModified { // Replace the 304 response with the one from cache, but update with some new headers endToEndHeaders := getEndToEndHeaders(resp.Header) for _, header := range endToEndHeaders { cachedResp.Header[header] = resp.Header[header] } resp = cachedResp } else if (err != nil || resp.StatusCode >= 500) && req.Method != http.MethodHead && canStaleOnError(cachedResp.Header, req.Header) { // In case of transport failure and stale-if-error activated, returns cached content // when available return cachedResp, nil } else { if err != nil || resp.StatusCode != http.StatusOK { t.Cache.Delete(cacheKey) } if err != nil { return nil, err } } } else { reqCacheControl := parseCacheControl(req.Header) if _, ok := reqCacheControl["only-if-cached"]; ok { resp = newGatewayTimeoutResponse(req) } else { resp, err = transport.RoundTrip(req) if err != nil { return nil, err } } } if cacheable && t.ShouldCache(req, resp, cacheKey) && t.CanStore(parseCacheControl(req.Header), parseCacheControl(resp.Header)) { for _, varyKey := range headerAllCommaSepValues(resp.Header, "vary") { varyKey = http.CanonicalHeaderKey(varyKey) fakeHeader := "X-Varied-" + varyKey reqValue := req.Header.Get(varyKey) if reqValue != "" { resp.Header.Set(fakeHeader, reqValue) } } switch req.Method { case http.MethodHead: respBytes, err := httputil.DumpResponse(resp, true) if err == nil { t.Cache.Set(cacheKey, respBytes) } default: var ( etagHash hash.Hash etag1 = cachedXEtag etag2 string ) r := resp.Body if t.EnableETagPair { if etag := resp.Header.Get("etag"); etag != "" { etag1 = etag if etag2 == "" { etag2 = etag } } else { etagHash = md5.New() r = struct { io.Reader io.Closer }{ io.TeeReader(r, etagHash), resp.Body, } } } r = &cachingReadCloser{ R: r, OnEOF: func(r io.Reader) { if etagHash != nil { md5Str := hex.EncodeToString(etagHash.Sum(nil)) etag2 = md5Str resp.Header.Set(XETag1, md5Str) resp.Header.Set(XETag2, md5Str) if etag1 == "" { etag1 = md5Str } } else { resp.Header.Set(XETag1, etag1) resp.Header.Set(XETag2, etag1) } resp := *resp resp.Body = io.NopCloser(r) respBytes, err := httputil.DumpResponse(&resp, true) if err == nil { // Signal any change back to the caller. resp.Header.Set(XETag1, etag1) t.Cache.Set(cacheKey, respBytes) } }, buf: &bytes.Buffer{}, } // Delay caching until EOF is reached. resp.Body = r } } else { t.Cache.Delete(cacheKey) } return resp, nil } // ErrNoDateHeader indicates that the HTTP headers contained no Date header. var ErrNoDateHeader = errors.New("no Date header") // date parses and returns the value of the date header. func date(respHeaders http.Header) (date time.Time, err error) { dateHeader := respHeaders.Get("date") if dateHeader == "" { err = ErrNoDateHeader return } return time.Parse(time.RFC1123, dateHeader) } type realClock struct{} func (c *realClock) since(d time.Time) time.Duration { return time.Since(d) } type timer interface { since(d time.Time) time.Duration } var clock timer = &realClock{} func getXETags(h http.Header) (string, string) { return h.Get(XETag1), h.Get(XETag2) } // getFreshness will return one of fresh/stale/transparent based on the cache-control // values of the request and the response // // fresh indicates the response can be returned // stale indicates that the response needs validating before it is returned // transparent indicates the response should not be used to fulfil the request // // Because this is only a private cache, 'public' and 'private' in cache-control aren't // significant. Similarly, smax-age isn't used. func getFreshness(respHeaders, reqHeaders http.Header) (freshness int) { respCacheControl := parseCacheControl(respHeaders) reqCacheControl := parseCacheControl(reqHeaders) if _, ok := reqCacheControl["no-cache"]; ok { return transparent } if _, ok := respCacheControl["no-cache"]; ok { return stale } if _, ok := reqCacheControl["only-if-cached"]; ok { return fresh } date, err := date(respHeaders) if err != nil { return stale } currentAge := clock.since(date) var lifetime time.Duration var zeroDuration time.Duration // If a response includes both an Expires header and a max-age directive, // the max-age directive overrides the Expires header, even if the Expires header is more restrictive. if maxAge, ok := respCacheControl["max-age"]; ok { lifetime, err = time.ParseDuration(maxAge + "s") if err != nil { lifetime = zeroDuration } } else { expiresHeader := respHeaders.Get("Expires") if expiresHeader != "" { expires, err := time.Parse(time.RFC1123, expiresHeader) if err != nil { lifetime = zeroDuration } else { lifetime = expires.Sub(date) } } } if maxAge, ok := reqCacheControl["max-age"]; ok { // the client is willing to accept a response whose age is no greater than the specified time in seconds lifetime, err = time.ParseDuration(maxAge + "s") if err != nil { lifetime = zeroDuration } } if minfresh, ok := reqCacheControl["min-fresh"]; ok { // the client wants a response that will still be fresh for at least the specified number of seconds. minfreshDuration, err := time.ParseDuration(minfresh + "s") if err == nil { currentAge = time.Duration(currentAge + minfreshDuration) } } if maxstale, ok := reqCacheControl["max-stale"]; ok { // Indicates that the client is willing to accept a response that has exceeded its expiration time. // If max-stale is assigned a value, then the client is willing to accept a response that has exceeded // its expiration time by no more than the specified number of seconds. // If no value is assigned to max-stale, then the client is willing to accept a stale response of any age. // // Responses served only because of a max-stale value are supposed to have a Warning header added to them, // but that seems like a hassle, and is it actually useful? If so, then there needs to be a different // return-value available here. if maxstale == "" { return fresh } maxstaleDuration, err := time.ParseDuration(maxstale + "s") if err == nil { currentAge = time.Duration(currentAge - maxstaleDuration) } } if lifetime > currentAge { return fresh } return stale } // Returns true if either the request or the response includes the stale-if-error // cache control extension: https://tools.ietf.org/html/rfc5861 func canStaleOnError(respHeaders, reqHeaders http.Header) bool { respCacheControl := parseCacheControl(respHeaders) reqCacheControl := parseCacheControl(reqHeaders) var err error lifetime := time.Duration(-1) if staleMaxAge, ok := respCacheControl["stale-if-error"]; ok { if staleMaxAge != "" { lifetime, err = time.ParseDuration(staleMaxAge + "s") if err != nil { return false } } else { return true } } if staleMaxAge, ok := reqCacheControl["stale-if-error"]; ok { if staleMaxAge != "" { lifetime, err = time.ParseDuration(staleMaxAge + "s") if err != nil { return false } } else { return true } } if lifetime >= 0 { date, err := date(respHeaders) if err != nil { return false } currentAge := clock.since(date) if lifetime > currentAge { return true } } return false } func getEndToEndHeaders(respHeaders http.Header) []string { // These headers are always hop-by-hop hopByHopHeaders := map[string]struct{}{ "Connection": {}, "Keep-Alive": {}, "Proxy-Authenticate": {}, "Proxy-Authorization": {}, "Te": {}, "Trailers": {}, "Transfer-Encoding": {}, "Upgrade": {}, } for _, extra := range strings.Split(respHeaders.Get("connection"), ",") { // any header listed in connection, if present, is also considered hop-by-hop if strings.Trim(extra, " ") != "" { hopByHopHeaders[http.CanonicalHeaderKey(extra)] = struct{}{} } } endToEndHeaders := []string{} for respHeader := range respHeaders { if _, ok := hopByHopHeaders[respHeader]; !ok { endToEndHeaders = append(endToEndHeaders, respHeader) } } return endToEndHeaders } func canStore(reqCacheControl, respCacheControl CacheControl) (canStore bool) { if _, ok := respCacheControl["no-store"]; ok { return false } if _, ok := reqCacheControl["no-store"]; ok { return false } return true } func newGatewayTimeoutResponse(req *http.Request) *http.Response { var braw bytes.Buffer braw.WriteString("HTTP/1.1 504 Gateway Timeout\r\n\r\n") resp, err := http.ReadResponse(bufio.NewReader(&braw), req) if err != nil { panic(err) } return resp } // cloneRequest returns a clone of the provided *http.Request. // The clone is a shallow copy of the struct and its Header map. // (This function copyright goauth2 authors: https://code.google.com/p/goauth2) func cloneRequest(r *http.Request) *http.Request { // shallow copy of the struct r2 := new(http.Request) *r2 = *r // deep copy of the Header r2.Header = make(http.Header) for k, s := range r.Header { r2.Header[k] = s } return r2 } // CacheControl is a map of the cache-control directives to their values (or "" if no value). type CacheControl map[string]string func parseCacheControl(headers http.Header) CacheControl { cc := CacheControl{} ccHeader := headers.Get("Cache-Control") for _, part := range strings.Split(ccHeader, ",") { part = strings.Trim(part, " ") if part == "" { continue } if strings.ContainsRune(part, '=') { keyval := strings.Split(part, "=") cc[strings.Trim(keyval[0], " ")] = strings.Trim(keyval[1], ",") } else { cc[part] = "" } } return cc } // headerAllCommaSepValues returns all comma-separated values (each // with whitespace trimmed) for header name in headers. According to // Section 4.2 of the HTTP/1.1 spec // (http://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2), // values from multiple occurrences of a header should be concatenated, if // the header's value is a comma-separated list. func headerAllCommaSepValues(headers http.Header, name string) []string { var vals []string for _, val := range headers[http.CanonicalHeaderKey(name)] { fields := strings.Split(val, ",") for i, f := range fields { fields[i] = strings.TrimSpace(f) } vals = append(vals, fields...) } return vals } // cachingReadCloser is a wrapper around ReadCloser R that calls OnEOF // handler with a full copy of the content read from R when EOF is // reached. type cachingReadCloser struct { // Underlying ReadCloser. R io.ReadCloser // OnEOF is called with a copy of the content of R when EOF is reached. OnEOF func(io.Reader) buf *bytes.Buffer // buf stores a copy of the content of R. } // Read reads the next len(p) bytes from R or until R is drained. The // return value n is the number of bytes read. If R has no data to // return, err is io.EOF and OnEOF is called with a full copy of what // has been read so far. func (r *cachingReadCloser) Read(p []byte) (n int, err error) { n, err = r.R.Read(p) r.buf.Write(p[:n]) if err == io.EOF { r.OnEOF(r.buf) } return n, err } func (r *cachingReadCloser) Close() error { return r.R.Close() } gohugoio-httpcache-244e49b/httpcache_test.go000066400000000000000000001121321506646360700212000ustar00rootroot00000000000000package httpcache import ( "bytes" "errors" "flag" "io" "net/http" "net/http/httptest" "os" "strconv" "sync" "testing" "time" qt "github.com/frankban/quicktest" ) var s struct { server *httptest.Server client http.Client transport *Transport done chan struct{} // Closed to unlock infinite handlers. } type fakeClock struct { elapsed time.Duration } func (c *fakeClock) since(t time.Time) time.Duration { return c.elapsed } func TestMain(m *testing.M) { flag.Parse() setup() code := m.Run() teardown() os.Exit(code) } func setup() { tp := newMemoryCacheTransport() client := http.Client{Transport: tp} s.transport = tp s.client = client s.done = make(chan struct{}) mux := http.NewServeMux() s.server = httptest.NewServer(mux) mux.HandleFunc("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "max-age=3600") })) mux.HandleFunc("/method", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "max-age=3600") w.Write([]byte(r.Method)) })) mux.HandleFunc("/helloheaderasbody", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(r.Header.Get("Hello"))) })) mux.HandleFunc("/range", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { lm := "Fri, 14 Dec 2010 01:01:50 GMT" if r.Header.Get("if-modified-since") == lm { w.WriteHeader(http.StatusNotModified) return } w.Header().Set("last-modified", lm) if r.Header.Get("range") == "bytes=4-9" { w.WriteHeader(http.StatusPartialContent) w.Write([]byte(" text ")) return } w.Write([]byte("Some text content")) })) mux.HandleFunc("/nostore", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") })) mux.HandleFunc("/etag", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { etag := "124567" if r.Header.Get("if-none-match") == etag { w.WriteHeader(http.StatusNotModified) return } w.Header().Set("etag", etag) })) mux.HandleFunc("/lastmodified", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { lm := "Fri, 14 Dec 2010 01:01:50 GMT" if r.Header.Get("if-modified-since") == lm { w.WriteHeader(http.StatusNotModified) return } w.Header().Set("last-modified", lm) })) mux.HandleFunc("/varyaccept", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "max-age=3600") w.Header().Set("Content-Type", "text/plain") w.Header().Set("Vary", "Accept") w.Write([]byte("Some text content")) })) mux.HandleFunc("/doublevary", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "max-age=3600") w.Header().Set("Content-Type", "text/plain") w.Header().Set("Vary", "Accept, Accept-Language") w.Write([]byte("Some text content")) })) mux.HandleFunc("/2varyheaders", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "max-age=3600") w.Header().Set("Content-Type", "text/plain") w.Header().Add("Vary", "Accept") w.Header().Add("Vary", "Accept-Language") w.Write([]byte("Some text content")) })) mux.HandleFunc("/varyunused", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "max-age=3600") w.Header().Set("Content-Type", "text/plain") w.Header().Set("Vary", "X-Madeup-Header") w.Write([]byte("Some text content")) })) mux.HandleFunc("/cachederror", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { etag := "abc" if r.Header.Get("if-none-match") == etag { w.WriteHeader(http.StatusNotModified) return } w.Header().Set("etag", etag) w.WriteHeader(http.StatusNotFound) w.Write([]byte("Not found")) })) updateFieldsCounter := 0 mux.HandleFunc("/updatefields", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Counter", strconv.Itoa(updateFieldsCounter)) w.Header().Set("Etag", `"e"`) updateFieldsCounter++ if r.Header.Get("if-none-match") != "" { w.WriteHeader(http.StatusNotModified) return } w.Write([]byte("Some text content")) })) // Take 3 seconds to return 200 OK (for testing client timeouts). mux.HandleFunc("/3seconds", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { time.Sleep(3 * time.Second) })) mux.HandleFunc("/infinite", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { for { select { case <-s.done: return default: w.Write([]byte{0}) } } })) } func teardown() { close(s.done) s.server.Close() } func cacheSize() int { return s.transport.Cache.(*memoryCache).Size() } func resetTest() { s.transport.Cache = newMemoryCache() s.transport.CacheKey = nil s.transport.AlwaysUseCachedResponse = nil s.transport.ShouldCache = nil s.transport.CanStore = nil s.transport.EnableETagPair = false s.transport.MarkCachedResponses = false s.transport.doInit() clock = &realClock{} } // TestCacheableMethod ensures that uncacheable method does not get stored // in cache and get incorrectly used for a following cacheable method request. func TestCacheableMethod(t *testing.T) { resetTest() c := qt.New(t) { body, resp := doMethod(t, "POST", "/method", nil) c.Assert(resp.StatusCode, qt.Equals, http.StatusOK) c.Assert(body, qt.Equals, "POST") } { body, resp := doMethod(t, "GET", "/method", nil) c.Assert(resp.StatusCode, qt.Equals, http.StatusOK) c.Assert(body, qt.Equals, "GET") c.Assert(resp.Header.Get(XFromCache), qt.Equals, "") } } func TestCacheKey(t *testing.T) { resetTest() c := qt.New(t) s.transport.CacheKey = func(req *http.Request) string { return "foo" } _, resp := doMethod(t, "GET", "/method", nil) c.Assert(resp.StatusCode, qt.Equals, http.StatusOK) _, ok := s.transport.Cache.Get("foo") c.Assert(ok, qt.Equals, true) } func TestEnableETagPair(t *testing.T) { resetTest() c := qt.New(t) s.transport.EnableETagPair = true { _, resp := doMethod(t, "GET", "/etag", nil) c.Assert(resp.StatusCode, qt.Equals, http.StatusOK) c.Assert(resp.Header.Get(XETag1), qt.Equals, "124567") c.Assert(resp.Header.Get(XETag2), qt.Equals, "124567") } { _, resp := doMethod(t, "GET", "/etag", nil) c.Assert(resp.StatusCode, qt.Equals, http.StatusOK) c.Assert(resp.Header.Get(XETag1), qt.Equals, "124567") c.Assert(resp.Header.Get(XETag2), qt.Equals, "124567") } // No HTTP caching in the following requests. { _, resp := doMethod(t, "GET", "/helloheaderasbody", map[string]string{"Hello": "world1"}) c.Assert(resp.StatusCode, qt.Equals, http.StatusOK) c.Assert(resp.Header.Get(XETag1), qt.Equals, "48b21a691481958c34cc165011bdb9bc") c.Assert(resp.Header.Get(XETag2), qt.Equals, "48b21a691481958c34cc165011bdb9bc") } { _, resp := doMethod(t, "GET", "/helloheaderasbody", map[string]string{"Hello": "world2"}) c.Assert(resp.StatusCode, qt.Equals, http.StatusOK) c.Assert(resp.Header.Get(XETag1), qt.Equals, "48b21a691481958c34cc165011bdb9bc") c.Assert(resp.Header.Get(XETag2), qt.Equals, "61b7d44bc024f189195b549bf094fbe8") } } func TestAlwaysUseCachedResponse(t *testing.T) { resetTest() c := qt.New(t) s.transport.AlwaysUseCachedResponse = func(req *http.Request, key string) bool { return req.Header.Get("Hello") == "world2" } { s, _ := doMethod(t, "GET", "/helloheaderasbody", map[string]string{"Hello": "world1"}) c.Assert(s, qt.Equals, "world1") } { s, _ := doMethod(t, "GET", "/helloheaderasbody", map[string]string{"Hello": "world2"}) c.Assert(s, qt.Equals, "world1") } { s, _ := doMethod(t, "GET", "/helloheaderasbody", map[string]string{"Hello": "world3"}) c.Assert(s, qt.Equals, "world3") } } func TestShouldCache(t *testing.T) { resetTest() c := qt.New(t) s.transport.AlwaysUseCachedResponse = func(req *http.Request, key string) bool { return true } s.transport.ShouldCache = func(req *http.Request, resp *http.Response, key string) bool { return req.Header.Get("Hello") == "world2" } { s, _ := doMethod(t, "GET", "/helloheaderasbody", map[string]string{"Hello": "world1"}) c.Assert(s, qt.Equals, "world1") } { s, _ := doMethod(t, "GET", "/helloheaderasbody", map[string]string{"Hello": "world2"}) c.Assert(s, qt.Equals, "world2") } { s, _ := doMethod(t, "GET", "/helloheaderasbody", map[string]string{"Hello": "world3"}) c.Assert(s, qt.Equals, "world2") } } func TestStaleCachedResponse(t *testing.T) { resetTest() s.transport.Cache = &staleCache{} s.transport.AlwaysUseCachedResponse = func(req *http.Request, key string) bool { return true } s.transport.EnableETagPair = true c := qt.New(t) { _, resp := doMethod(t, "GET", "/helloheaderasbody", map[string]string{"Hello": "world1"}) c.Assert(resp.StatusCode, qt.Equals, http.StatusOK) c.Assert(resp.Header.Get(XETag1), qt.Equals, "48b21a691481958c34cc165011bdb9bc") c.Assert(resp.Header.Get(XETag2), qt.Equals, "48b21a691481958c34cc165011bdb9bc") } { _, resp := doMethod(t, "GET", "/helloheaderasbody", map[string]string{"Hello": "world2"}) c.Assert(resp.StatusCode, qt.Equals, http.StatusOK) c.Assert(resp.Header.Get(XETag1), qt.Equals, "48b21a691481958c34cc165011bdb9bc") c.Assert(resp.Header.Get(XETag2), qt.Equals, "61b7d44bc024f189195b549bf094fbe8") } } func TestAround(t *testing.T) { resetTest() c := qt.New(t) count := 0 s.transport.Around = func(req *http.Request, key string) func() { count++ return func() { count++ } } _, resp := doMethod(t, "GET", "/method", nil) c.Assert(resp.StatusCode, qt.Equals, http.StatusOK) c.Assert(count, qt.Equals, 2) } func TestDontServeHeadResponseToGetRequest(t *testing.T) { resetTest() c := qt.New(t) doMethod(t, http.MethodHead, "/", nil) _, resp := doMethod(t, http.MethodGet, "/", nil) c.Assert(resp.Header.Get(XFromCache), qt.Equals, "") } func TestDontStorePartialRangeInCache(t *testing.T) { resetTest() c := qt.New(t) s.transport.MarkCachedResponses = true { body, resp := doMethod(t, "GET", "/range", map[string]string{"range": "bytes=4-9"}) c.Assert(resp.StatusCode, qt.Equals, http.StatusPartialContent) c.Assert(body, qt.Equals, " text ") c.Assert(cacheSize(), qt.Equals, 0) } { body, resp := doMethod(t, "GET", "/range", nil) c.Assert(resp.StatusCode, qt.Equals, http.StatusOK) c.Assert(body, qt.Equals, "Some text content") c.Assert(resp.Header.Get(XFromCache), qt.Equals, "") c.Assert(cacheSize(), qt.Equals, 1) } { body, resp := doMethod(t, "GET", "/range", nil) c.Assert(resp.StatusCode, qt.Equals, http.StatusOK) c.Assert(body, qt.Equals, "Some text content") c.Assert(resp.Header.Get(XFromCache), qt.Equals, "1") c.Assert(cacheSize(), qt.Equals, 1) } { body, resp := doMethod(t, "GET", "/range", map[string]string{"range": "bytes=4-9"}) c.Assert(resp.StatusCode, qt.Equals, http.StatusPartialContent) c.Assert(body, qt.Equals, " text ") c.Assert(cacheSize(), qt.Equals, 1) } } func TestCacheOnlyIfBodyRead(t *testing.T) { resetTest() { req, err := http.NewRequest("GET", s.server.URL, nil) if err != nil { t.Fatal(err) } resp, err := s.client.Do(req) if err != nil { t.Fatal(err) } if resp.Header.Get(XFromCache) != "" { t.Fatal("XFromCache header isn't blank") } // We do not read the body resp.Body.Close() } { req, err := http.NewRequest("GET", s.server.URL, nil) if err != nil { t.Fatal(err) } resp, err := s.client.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.Header.Get(XFromCache) != "" { t.Fatalf("XFromCache header isn't blank") } } } func TestOnlyReadBodyOnDemand(t *testing.T) { resetTest() req, err := http.NewRequest("GET", s.server.URL+"/infinite", nil) if err != nil { t.Fatal(err) } resp, err := s.client.Do(req) // This shouldn't hang forever. if err != nil { t.Fatal(err) } buf := make([]byte, 10) // Only partially read the body. _, err = resp.Body.Read(buf) if err != nil { t.Fatal(err) } resp.Body.Close() } func TestGetOnlyIfCachedHit(t *testing.T) { resetTest() c := qt.New(t) s.transport.MarkCachedResponses = true { _, resp := doMethod(t, "GET", "/", nil) c.Assert(resp.StatusCode, qt.Equals, http.StatusOK) c.Assert(resp.Header.Get(XFromCache), qt.Equals, "") c.Assert(cacheSize(), qt.Equals, 1) } { _, resp := doMethod(t, "GET", "/", map[string]string{"cache-control": "only-if-cached"}) c.Assert(resp.StatusCode, qt.Equals, http.StatusOK) c.Assert(resp.Header.Get(XFromCache), qt.Equals, "1") c.Assert(cacheSize(), qt.Equals, 1) } } func TestGetOnlyIfCachedMiss(t *testing.T) { resetTest() s.transport.MarkCachedResponses = true c := qt.New(t) _, resp := doMethod(t, "GET", "/", map[string]string{"cache-control": "only-if-cached"}) c.Assert(resp.StatusCode, qt.Equals, http.StatusGatewayTimeout) c.Assert(resp.Header.Get(XFromCache), qt.Equals, "") c.Assert(cacheSize(), qt.Equals, 1) } func TestGetNoStoreRequest(t *testing.T) { resetTest() s.transport.MarkCachedResponses = true c := qt.New(t) for i := 0; i < 2; i++ { _, resp := doMethod(t, "GET", "/", map[string]string{"cache-control": "no-store"}) c.Assert(resp.StatusCode, qt.Equals, http.StatusOK) c.Assert(resp.Header.Get(XFromCache), qt.Equals, "") c.Assert(cacheSize(), qt.Equals, 0) } } func TestGetNoStoreResponse(t *testing.T) { resetTest() s.transport.MarkCachedResponses = true c := qt.New(t) for i := 0; i < 2; i++ { _, resp := doMethod(t, "GET", "/nostore", nil) c.Assert(resp.StatusCode, qt.Equals, http.StatusOK) c.Assert(resp.Header.Get(XFromCache), qt.Equals, "") c.Assert(cacheSize(), qt.Equals, 0) } } func TestGetWithEtag(t *testing.T) { resetTest() s.transport.MarkCachedResponses = true c := qt.New(t) { _, resp := doMethod(t, "GET", "/etag", nil) c.Assert(resp.StatusCode, qt.Equals, http.StatusOK) c.Assert(resp.Header.Get(XFromCache), qt.Equals, "") c.Assert(cacheSize(), qt.Equals, 1) } { _, resp := doMethod(t, "GET", "/etag", nil) c.Assert(resp.StatusCode, qt.Equals, http.StatusOK) c.Assert(resp.Header.Get(XFromCache), qt.Equals, "1") c.Assert(cacheSize(), qt.Equals, 1) _, ok := resp.Header["Connection"] c.Assert(ok, qt.IsFalse) } } func TestGetWithLastModified(t *testing.T) { resetTest() s.transport.MarkCachedResponses = true c := qt.New(t) { _, resp := doMethod(t, "GET", "/lastmodified", nil) c.Assert(resp.StatusCode, qt.Equals, http.StatusOK) c.Assert(resp.Header.Get(XFromCache), qt.Equals, "") c.Assert(cacheSize(), qt.Equals, 1) } { _, resp := doMethod(t, "GET", "/lastmodified", nil) c.Assert(resp.StatusCode, qt.Equals, http.StatusOK) c.Assert(resp.Header.Get(XFromCache), qt.Equals, "1") c.Assert(cacheSize(), qt.Equals, 1) } } func TestGetWithVary(t *testing.T) { resetTest() s.transport.MarkCachedResponses = true c := qt.New(t) { _, resp := doMethod(t, "GET", "/varyaccept", map[string]string{"Accept": "text/plain"}) c.Assert(resp.StatusCode, qt.Equals, http.StatusOK) c.Assert(resp.Header.Get(XFromCache), qt.Equals, "") c.Assert(cacheSize(), qt.Equals, 1) c.Assert(resp.Header.Get("Vary"), qt.Equals, "Accept") } { _, resp := doMethod(t, "GET", "/varyaccept", map[string]string{"Accept": "text/plain"}) c.Assert(resp.StatusCode, qt.Equals, http.StatusOK) c.Assert(resp.Header.Get(XFromCache), qt.Equals, "1") } { _, resp := doMethod(t, "GET", "/varyaccept", map[string]string{"Accept": "text/html"}) c.Assert(resp.StatusCode, qt.Equals, http.StatusOK) c.Assert(resp.Header.Get(XFromCache), qt.Equals, "") c.Assert(cacheSize(), qt.Equals, 1) c.Assert(resp.Header.Get("Vary"), qt.Equals, "Accept") } { _, resp := doMethod(t, "GET", "/varyaccept", map[string]string{"Accept": ""}) c.Assert(resp.StatusCode, qt.Equals, http.StatusOK) c.Assert(resp.Header.Get(XFromCache), qt.Equals, "") c.Assert(cacheSize(), qt.Equals, 1) c.Assert(resp.Header.Get("Vary"), qt.Equals, "Accept") } } func TestGetWithDoubleVary(t *testing.T) { resetTest() s.transport.MarkCachedResponses = true req, err := http.NewRequest("GET", s.server.URL+"/doublevary", nil) if err != nil { t.Fatal(err) } req.Header.Set("Accept", "text/plain") req.Header.Set("Accept-Language", "da, en-gb;q=0.8, en;q=0.7") { resp, err := s.client.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.Header.Get("Vary") == "" { t.Fatalf(`Vary header is blank`) } _, err = io.ReadAll(resp.Body) if err != nil { t.Fatal(err) } } { resp, err := s.client.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.Header.Get(XFromCache) != "1" { t.Fatalf(`XFromCache header isn't "1": %v`, resp.Header.Get(XFromCache)) } } req.Header.Set("Accept-Language", "") { resp, err := s.client.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.Header.Get(XFromCache) != "" { t.Fatal("XFromCache header isn't blank") } } req.Header.Set("Accept-Language", "da") { resp, err := s.client.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.Header.Get(XFromCache) != "" { t.Fatal("XFromCache header isn't blank") } } } func TestGetWith2VaryHeaders(t *testing.T) { resetTest() s.transport.MarkCachedResponses = true // Tests that multiple Vary headers' comma-separated lists are // merged. See https://github.com/gregjones/httpcache/issues/27. const ( accept = "text/plain" acceptLanguage = "da, en-gb;q=0.8, en;q=0.7" ) req, err := http.NewRequest("GET", s.server.URL+"/2varyheaders", nil) if err != nil { t.Fatal(err) } req.Header.Set("Accept", accept) req.Header.Set("Accept-Language", acceptLanguage) { resp, err := s.client.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.Header.Get("Vary") == "" { t.Fatalf(`Vary header is blank`) } _, err = io.ReadAll(resp.Body) if err != nil { t.Fatal(err) } } { resp, err := s.client.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.Header.Get(XFromCache) != "1" { t.Fatalf(`XFromCache header isn't "1": %v`, resp.Header.Get(XFromCache)) } } req.Header.Set("Accept-Language", "") { resp, err := s.client.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.Header.Get(XFromCache) != "" { t.Fatal("XFromCache header isn't blank") } } req.Header.Set("Accept-Language", "da") { resp, err := s.client.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.Header.Get(XFromCache) != "" { t.Fatal("XFromCache header isn't blank") } } req.Header.Set("Accept-Language", acceptLanguage) req.Header.Set("Accept", "") { resp, err := s.client.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.Header.Get(XFromCache) != "" { t.Fatal("XFromCache header isn't blank") } } req.Header.Set("Accept", "image/png") { resp, err := s.client.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.Header.Get(XFromCache) != "" { t.Fatal("XFromCache header isn't blank") } _, err = io.ReadAll(resp.Body) if err != nil { t.Fatal(err) } } { resp, err := s.client.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.Header.Get(XFromCache) != "1" { t.Fatalf(`XFromCache header isn't "1": %v`, resp.Header.Get(XFromCache)) } } } func TestGetVaryUnused(t *testing.T) { resetTest() s.transport.MarkCachedResponses = true req, err := http.NewRequest("GET", s.server.URL+"/varyunused", nil) if err != nil { t.Fatal(err) } req.Header.Set("Accept", "text/plain") { resp, err := s.client.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.Header.Get("Vary") == "" { t.Fatalf(`Vary header is blank`) } _, err = io.ReadAll(resp.Body) if err != nil { t.Fatal(err) } } { resp, err := s.client.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.Header.Get(XFromCache) != "1" { t.Fatalf(`XFromCache header isn't "1": %v`, resp.Header.Get(XFromCache)) } } } func TestUpdateFields(t *testing.T) { resetTest() s.transport.MarkCachedResponses = true req, err := http.NewRequest("GET", s.server.URL+"/updatefields", nil) if err != nil { t.Fatal(err) } var counter, counter2 string { resp, err := s.client.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() counter = resp.Header.Get("x-counter") _, err = io.ReadAll(resp.Body) if err != nil { t.Fatal(err) } } { resp, err := s.client.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.Header.Get(XFromCache) != "1" { t.Fatalf(`XFromCache header isn't "1": %v`, resp.Header.Get(XFromCache)) } counter2 = resp.Header.Get("x-counter") } if counter == counter2 { t.Fatalf(`both "x-counter" values are equal: %v %v`, counter, counter2) } } // This tests the fix for https://github.com/gregjones/httpcache/issues/74. // Previously, after validating a cached response, its StatusCode // was incorrectly being replaced. func TestCachedErrorsKeepStatus(t *testing.T) { resetTest() req, err := http.NewRequest("GET", s.server.URL+"/cachederror", nil) if err != nil { t.Fatal(err) } { resp, err := s.client.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() io.Copy(io.Discard, resp.Body) } { resp, err := s.client.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusNotFound { t.Fatalf("Status code isn't 404: %d", resp.StatusCode) } } } func TestParseCacheControl(t *testing.T) { resetTest() h := http.Header{} for range parseCacheControl(h) { t.Fatal("cacheControl should be empty") } h.Set("cache-control", "no-cache") { cc := parseCacheControl(h) if _, ok := cc["foo"]; ok { t.Error(`Value "foo" shouldn't exist`) } noCache, ok := cc["no-cache"] if !ok { t.Fatalf(`"no-cache" value isn't set`) } if noCache != "" { t.Fatalf(`"no-cache" value isn't blank: %v`, noCache) } } h.Set("cache-control", "no-cache, max-age=3600") { cc := parseCacheControl(h) noCache, ok := cc["no-cache"] if !ok { t.Fatalf(`"no-cache" value isn't set`) } if noCache != "" { t.Fatalf(`"no-cache" value isn't blank: %v`, noCache) } if cc["max-age"] != "3600" { t.Fatalf(`"max-age" value isn't "3600": %v`, cc["max-age"]) } } } func TestNoCacheRequestExpiration(t *testing.T) { resetTest() respHeaders := http.Header{} respHeaders.Set("Cache-Control", "max-age=7200") reqHeaders := http.Header{} reqHeaders.Set("Cache-Control", "no-cache") if getFreshness(respHeaders, reqHeaders) != transparent { t.Fatal("freshness isn't transparent") } } func TestNoCacheResponseExpiration(t *testing.T) { resetTest() respHeaders := http.Header{} respHeaders.Set("Cache-Control", "no-cache") respHeaders.Set("Expires", "Wed, 19 Apr 3000 11:43:00 GMT") reqHeaders := http.Header{} if getFreshness(respHeaders, reqHeaders) != stale { t.Fatal("freshness isn't stale") } } func TestReqMustRevalidate(t *testing.T) { resetTest() // not paying attention to request setting max-stale means never returning stale // responses, so always acting as if must-revalidate is set respHeaders := http.Header{} reqHeaders := http.Header{} reqHeaders.Set("Cache-Control", "must-revalidate") if getFreshness(respHeaders, reqHeaders) != stale { t.Fatal("freshness isn't stale") } } func TestRespMustRevalidate(t *testing.T) { resetTest() respHeaders := http.Header{} respHeaders.Set("Cache-Control", "must-revalidate") reqHeaders := http.Header{} if getFreshness(respHeaders, reqHeaders) != stale { t.Fatal("freshness isn't stale") } } func TestFreshExpiration(t *testing.T) { resetTest() now := time.Now() respHeaders := http.Header{} respHeaders.Set("date", now.Format(time.RFC1123)) respHeaders.Set("expires", now.Add(time.Duration(2)*time.Second).Format(time.RFC1123)) reqHeaders := http.Header{} if getFreshness(respHeaders, reqHeaders) != fresh { t.Fatal("freshness isn't fresh") } clock = &fakeClock{elapsed: 3 * time.Second} if getFreshness(respHeaders, reqHeaders) != stale { t.Fatal("freshness isn't stale") } } func TestMaxAge(t *testing.T) { resetTest() now := time.Now() respHeaders := http.Header{} respHeaders.Set("date", now.Format(time.RFC1123)) respHeaders.Set("cache-control", "max-age=2") reqHeaders := http.Header{} if getFreshness(respHeaders, reqHeaders) != fresh { t.Fatal("freshness isn't fresh") } clock = &fakeClock{elapsed: 3 * time.Second} if getFreshness(respHeaders, reqHeaders) != stale { t.Fatal("freshness isn't stale") } } func TestMaxAgeZero(t *testing.T) { resetTest() now := time.Now() respHeaders := http.Header{} respHeaders.Set("date", now.Format(time.RFC1123)) respHeaders.Set("cache-control", "max-age=0") reqHeaders := http.Header{} if getFreshness(respHeaders, reqHeaders) != stale { t.Fatal("freshness isn't stale") } } func TestBothMaxAge(t *testing.T) { resetTest() now := time.Now() respHeaders := http.Header{} respHeaders.Set("date", now.Format(time.RFC1123)) respHeaders.Set("cache-control", "max-age=2") reqHeaders := http.Header{} reqHeaders.Set("cache-control", "max-age=0") if getFreshness(respHeaders, reqHeaders) != stale { t.Fatal("freshness isn't stale") } } func TestMinFreshWithExpires(t *testing.T) { resetTest() now := time.Now() respHeaders := http.Header{} respHeaders.Set("date", now.Format(time.RFC1123)) respHeaders.Set("expires", now.Add(time.Duration(2)*time.Second).Format(time.RFC1123)) reqHeaders := http.Header{} reqHeaders.Set("cache-control", "min-fresh=1") if getFreshness(respHeaders, reqHeaders) != fresh { t.Fatal("freshness isn't fresh") } reqHeaders = http.Header{} reqHeaders.Set("cache-control", "min-fresh=2") if getFreshness(respHeaders, reqHeaders) != stale { t.Fatal("freshness isn't stale") } } func TestEmptyMaxStale(t *testing.T) { resetTest() now := time.Now() respHeaders := http.Header{} respHeaders.Set("date", now.Format(time.RFC1123)) respHeaders.Set("cache-control", "max-age=20") reqHeaders := http.Header{} reqHeaders.Set("cache-control", "max-stale") clock = &fakeClock{elapsed: 10 * time.Second} if getFreshness(respHeaders, reqHeaders) != fresh { t.Fatal("freshness isn't fresh") } clock = &fakeClock{elapsed: 60 * time.Second} if getFreshness(respHeaders, reqHeaders) != fresh { t.Fatal("freshness isn't fresh") } } func TestMaxStaleValue(t *testing.T) { resetTest() now := time.Now() respHeaders := http.Header{} respHeaders.Set("date", now.Format(time.RFC1123)) respHeaders.Set("cache-control", "max-age=10") reqHeaders := http.Header{} reqHeaders.Set("cache-control", "max-stale=20") clock = &fakeClock{elapsed: 5 * time.Second} if getFreshness(respHeaders, reqHeaders) != fresh { t.Fatal("freshness isn't fresh") } clock = &fakeClock{elapsed: 15 * time.Second} if getFreshness(respHeaders, reqHeaders) != fresh { t.Fatal("freshness isn't fresh") } clock = &fakeClock{elapsed: 30 * time.Second} if getFreshness(respHeaders, reqHeaders) != stale { t.Fatal("freshness isn't stale") } } func containsHeader(headers []string, header string) bool { for _, v := range headers { if http.CanonicalHeaderKey(v) == http.CanonicalHeaderKey(header) { return true } } return false } func TestGetEndToEndHeaders(t *testing.T) { resetTest() var ( headers http.Header end2end []string ) headers = http.Header{} headers.Set("content-type", "text/html") headers.Set("te", "deflate") end2end = getEndToEndHeaders(headers) if !containsHeader(end2end, "content-type") { t.Fatal(`doesn't contain "content-type" header`) } if containsHeader(end2end, "te") { t.Fatal(`doesn't contain "te" header`) } headers = http.Header{} headers.Set("connection", "content-type") headers.Set("content-type", "text/csv") headers.Set("te", "deflate") end2end = getEndToEndHeaders(headers) if containsHeader(end2end, "connection") { t.Fatal(`doesn't contain "connection" header`) } if containsHeader(end2end, "content-type") { t.Fatal(`doesn't contain "content-type" header`) } if containsHeader(end2end, "te") { t.Fatal(`doesn't contain "te" header`) } headers = http.Header{} end2end = getEndToEndHeaders(headers) if len(end2end) != 0 { t.Fatal(`non-zero end2end headers`) } headers = http.Header{} headers.Set("connection", "content-type") end2end = getEndToEndHeaders(headers) if len(end2end) != 0 { t.Fatal(`non-zero end2end headers`) } } type transportMock struct { response *http.Response err error } func (t transportMock) RoundTrip(req *http.Request) (resp *http.Response, err error) { return t.response, t.err } func TestStaleIfErrorRequest(t *testing.T) { resetTest() now := time.Now() tmock := transportMock{ response: &http.Response{ Status: http.StatusText(http.StatusOK), StatusCode: http.StatusOK, Header: http.Header{ "Date": []string{now.Format(time.RFC1123)}, "Cache-Control": []string{"no-cache"}, }, Body: io.NopCloser(bytes.NewBuffer([]byte("some data"))), }, err: nil, } tp := newMemoryCacheTransport() tp.Transport = &tmock // First time, response is cached on success r, _ := http.NewRequest("GET", "http://somewhere.com/", nil) r.Header.Set("Cache-Control", "stale-if-error") resp, err := tp.RoundTrip(r) if err != nil { t.Fatal(err) } if resp == nil { t.Fatal("resp is nil") } _, err = io.ReadAll(resp.Body) if err != nil { t.Fatal(err) } // On failure, response is returned from the cache tmock.response = nil tmock.err = errors.New("some error") resp, err = tp.RoundTrip(r) if err != nil { t.Fatal(err) } if resp == nil { t.Fatal("resp is nil") } } func TestStaleIfErrorRequestLifetime(t *testing.T) { resetTest() now := time.Now() tmock := transportMock{ response: &http.Response{ Status: http.StatusText(http.StatusOK), StatusCode: http.StatusOK, Header: http.Header{ "Date": []string{now.Format(time.RFC1123)}, "Cache-Control": []string{"no-cache"}, }, Body: io.NopCloser(bytes.NewBuffer([]byte("some data"))), }, err: nil, } tp := newMemoryCacheTransport() tp.Transport = &tmock // First time, response is cached on success r, _ := http.NewRequest("GET", "http://somewhere.com/", nil) r.Header.Set("Cache-Control", "stale-if-error=100") resp, err := tp.RoundTrip(r) if err != nil { t.Fatal(err) } if resp == nil { t.Fatal("resp is nil") } _, err = io.ReadAll(resp.Body) if err != nil { t.Fatal(err) } // On failure, response is returned from the cache tmock.response = nil tmock.err = errors.New("some error") resp, err = tp.RoundTrip(r) if err != nil { t.Fatal(err) } if resp == nil { t.Fatal("resp is nil") } // Same for http errors tmock.response = &http.Response{StatusCode: http.StatusInternalServerError} tmock.err = nil resp, err = tp.RoundTrip(r) if err != nil { t.Fatal(err) } if resp == nil { t.Fatal("resp is nil") } // If failure last more than max stale, error is returned clock = &fakeClock{elapsed: 200 * time.Second} _, err = tp.RoundTrip(r) if err != tmock.err { t.Fatalf("got err %v, want %v", err, tmock.err) } } func TestStaleIfErrorResponse(t *testing.T) { resetTest() now := time.Now() tmock := transportMock{ response: &http.Response{ Status: http.StatusText(http.StatusOK), StatusCode: http.StatusOK, Header: http.Header{ "Date": []string{now.Format(time.RFC1123)}, "Cache-Control": []string{"no-cache, stale-if-error"}, }, Body: io.NopCloser(bytes.NewBuffer([]byte("some data"))), }, err: nil, } tp := newMemoryCacheTransport() tp.Transport = &tmock // First time, response is cached on success r, _ := http.NewRequest("GET", "http://somewhere.com/", nil) resp, err := tp.RoundTrip(r) if err != nil { t.Fatal(err) } if resp == nil { t.Fatal("resp is nil") } _, err = io.ReadAll(resp.Body) if err != nil { t.Fatal(err) } // On failure, response is returned from the cache tmock.response = nil tmock.err = errors.New("some error") resp, err = tp.RoundTrip(r) if err != nil { t.Fatal(err) } if resp == nil { t.Fatal("resp is nil") } } func TestStaleIfErrorResponseLifetime(t *testing.T) { resetTest() now := time.Now() tmock := transportMock{ response: &http.Response{ Status: http.StatusText(http.StatusOK), StatusCode: http.StatusOK, Header: http.Header{ "Date": []string{now.Format(time.RFC1123)}, "Cache-Control": []string{"no-cache, stale-if-error=100"}, }, Body: io.NopCloser(bytes.NewBuffer([]byte("some data"))), }, err: nil, } tp := newMemoryCacheTransport() tp.Transport = &tmock // First time, response is cached on success r, _ := http.NewRequest("GET", "http://somewhere.com/", nil) resp, err := tp.RoundTrip(r) if err != nil { t.Fatal(err) } if resp == nil { t.Fatal("resp is nil") } _, err = io.ReadAll(resp.Body) if err != nil { t.Fatal(err) } // On failure, response is returned from the cache tmock.response = nil tmock.err = errors.New("some error") resp, err = tp.RoundTrip(r) if err != nil { t.Fatal(err) } if resp == nil { t.Fatal("resp is nil") } // If failure last more than max stale, error is returned clock = &fakeClock{elapsed: 200 * time.Second} _, err = tp.RoundTrip(r) if err != tmock.err { t.Fatalf("got err %v, want %v", err, tmock.err) } } // This tests the fix for https://github.com/gregjones/httpcache/issues/74. // Previously, after a stale response was used after encountering an error, // its StatusCode was being incorrectly replaced. func TestStaleIfErrorKeepsStatus(t *testing.T) { resetTest() now := time.Now() tmock := transportMock{ response: &http.Response{ Status: http.StatusText(http.StatusNotFound), StatusCode: http.StatusNotFound, Header: http.Header{ "Date": []string{now.Format(time.RFC1123)}, "Cache-Control": []string{"no-cache"}, }, Body: io.NopCloser(bytes.NewBuffer([]byte("some data"))), }, err: nil, } tp := newMemoryCacheTransport() tp.Transport = &tmock // First time, response is cached on success r, _ := http.NewRequest("GET", "http://somewhere.com/", nil) r.Header.Set("Cache-Control", "stale-if-error") resp, err := tp.RoundTrip(r) if err != nil { t.Fatal(err) } if resp == nil { t.Fatal("resp is nil") } _, err = io.ReadAll(resp.Body) if err != nil { t.Fatal(err) } // On failure, response is returned from the cache tmock.response = nil tmock.err = errors.New("some error") resp, err = tp.RoundTrip(r) if err != nil { t.Fatal(err) } if resp == nil { t.Fatal("resp is nil") } if resp.StatusCode != http.StatusNotFound { t.Fatalf("Status wasn't 404: %d", resp.StatusCode) } } // Test that http.Client.Timeout is respected when cache transport is used. // That is so as long as request cancellation is propagated correctly. // In the past, that required CancelRequest to be implemented correctly, // but modern http.Client uses Request.Cancel (or request context) instead, // so we don't have to do anything. func TestClientTimeout(t *testing.T) { if testing.Short() { t.Skip("skipping timeout test in short mode") // Because it takes at least 3 seconds to run. } resetTest() client := &http.Client{ Transport: newMemoryCacheTransport(), Timeout: time.Second, } started := time.Now() resp, err := client.Get(s.server.URL + "/3seconds") taken := time.Since(started) if err == nil { t.Error("got nil error, want timeout error") } if resp != nil { t.Error("got non-nil resp, want nil resp") } if taken >= 2*time.Second { t.Error("client.Do took 2+ seconds, want < 2 seconds") } } func doMethod(t testing.TB, method string, p string, headers map[string]string) (string, *http.Response) { t.Helper() req, err := http.NewRequest(method, s.server.URL+p, nil) if err != nil { t.Fatal(err) } if len(headers) > 0 { for k, v := range headers { req.Header.Set(k, v) } } resp, err := s.client.Do(req) if err != nil { t.Fatal(err) } var buf bytes.Buffer _, err = io.Copy(&buf, resp.Body) if err != nil { t.Fatal(err) } err = resp.Body.Close() if err != nil { t.Fatal(err) } return buf.String(), resp } // newMemoryCacheTransport returns a new Transport using the in-memory cache implementation func newMemoryCacheTransport() *Transport { c := newMemoryCache() t := &Transport{Cache: c} return t } // memoryCache is an implemtation of Cache that stores responses in an in-memory map. type memoryCache struct { mu sync.RWMutex items map[string][]byte } // newMemoryCache returns a new Cache that will store items in an in-memory map func newMemoryCache() *memoryCache { c := &memoryCache{items: map[string][]byte{}} return c } func (c *memoryCache) Size() int { c.mu.RLock() defer c.mu.RUnlock() return len(c.items) } // Get returns the []byte representation of the response and true if present, false if not func (c *memoryCache) Get(key string) (resp []byte, ok bool) { c.mu.RLock() resp, ok = c.items[key] c.mu.RUnlock() return resp, ok } // Set saves response resp to the cache with key func (c *memoryCache) Set(key string, resp []byte) { c.mu.Lock() c.items[key] = resp c.mu.Unlock() } // Delete removes key from the cache func (c *memoryCache) Delete(key string) { c.mu.Lock() delete(c.items, key) c.mu.Unlock() } var _ Cache = &staleCache{} type staleCache struct { val []byte } func (c *staleCache) Get(key string) ([]byte, bool) { return c.val, false } func (c *staleCache) Set(key string, resp []byte) { c.val = resp } func (c *staleCache) Delete(key string) { c.val = nil } func (c *staleCache) Size() int { return 1 }