├── .travis.yml ├── LICENSE ├── README.md ├── api.go ├── api_test.go ├── cacheobject ├── directive.go ├── directive_test.go ├── lex.go ├── object.go ├── object_http_test.go ├── object_test.go ├── reasons.go └── warning.go ├── doc.go ├── examples ├── example-com.go └── lowlevel │ └── ll-example-com.go ├── go.mod └── go.sum /.travis.yml: -------------------------------------------------------------------------------- 1 | arch: 2 | - amd64 3 | - ppc64le 4 | language: go 5 | 6 | go: 7 | - "1.15" 8 | - "1.16" 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cachecontrol: HTTP Caching Parser and Interpretation 2 | 3 | [![PkgGoDev](https://pkg.go.dev/badge/github.com/pquerna/cachecontrol?tab=doc)](https://pkg.go.dev/github.com/pquerna/cachecontrol?tab=doc)[![Build Status](https://travis-ci.org/pquerna/cachecontrol.svg?branch=main)](https://travis-ci.org/pquerna/cachecontrol) 4 | 5 | `cachecontrol` implements [RFC 7234](http://tools.ietf.org/html/rfc7234) __Hypertext Transfer Protocol (HTTP/1.1): Caching__. It does this by parsing the `Cache-Control` and other headers, providing information about requests and responses -- but `cachecontrol` does not implement an actual cache backend, just the control plane to make decisions about if a particular response is cachable. 6 | 7 | # Usage 8 | 9 | `cachecontrol.CachableResponse` returns an array of [reasons](https://godoc.org/github.com/pquerna/cachecontrol/cacheobject#Reason) why a response should not be cached and when it expires. In the case that `len(reasons) == 0`, the response is cachable according to the RFC. However, some people want non-compliant caches for various business use cases, so each reason is specifically named, so if your cache wants to cache `POST` requests, it can easily do that, but still be RFC compliant in other situations. 10 | 11 | # Examples 12 | 13 | ## Can you cache Example.com? 14 | 15 | ```go 16 | package main 17 | 18 | import ( 19 | "github.com/pquerna/cachecontrol" 20 | 21 | "fmt" 22 | "io/ioutil" 23 | "net/http" 24 | ) 25 | 26 | func main() { 27 | req, _ := http.NewRequest("GET", "http://www.example.com/", nil) 28 | 29 | res, _ := http.DefaultClient.Do(req) 30 | _, _ = ioutil.ReadAll(res.Body) 31 | 32 | reasons, expires, _ := cachecontrol.CachableResponse(req, res, cachecontrol.Options{}) 33 | 34 | fmt.Println("Reasons to not cache: ", reasons) 35 | fmt.Println("Expiration: ", expires.String()) 36 | } 37 | ``` 38 | 39 | ## Can I use this in a high performance caching server? 40 | 41 | `cachecontrol` is divided into two packages: `cachecontrol` with a high level API, and a lower level `cacheobject` package. Use [Object](https://godoc.org/github.com/pquerna/cachecontrol/cacheobject#Object) in a high performance use case where you have previously parsed headers containing dates or would like to avoid memory allocations. 42 | 43 | ```go 44 | package main 45 | 46 | import ( 47 | "github.com/pquerna/cachecontrol/cacheobject" 48 | 49 | "fmt" 50 | "io/ioutil" 51 | "net/http" 52 | ) 53 | 54 | func main() { 55 | req, _ := http.NewRequest("GET", "http://www.example.com/", nil) 56 | 57 | res, _ := http.DefaultClient.Do(req) 58 | _, _ = ioutil.ReadAll(res.Body) 59 | 60 | reqDir, _ := cacheobject.ParseRequestCacheControl(req.Header.Get("Cache-Control")) 61 | 62 | resDir, _ := cacheobject.ParseResponseCacheControl(res.Header.Get("Cache-Control")) 63 | expiresHeader, _ := http.ParseTime(res.Header.Get("Expires")) 64 | dateHeader, _ := http.ParseTime(res.Header.Get("Date")) 65 | lastModifiedHeader, _ := http.ParseTime(res.Header.Get("Last-Modified")) 66 | 67 | obj := cacheobject.Object{ 68 | RespDirectives: resDir, 69 | RespHeaders: res.Header, 70 | RespStatusCode: res.StatusCode, 71 | RespExpiresHeader: expiresHeader, 72 | RespDateHeader: dateHeader, 73 | RespLastModifiedHeader: lastModifiedHeader, 74 | 75 | ReqDirectives: reqDir, 76 | ReqHeaders: req.Header, 77 | ReqMethod: req.Method, 78 | 79 | NowUTC: time.Now().UTC(), 80 | } 81 | rv := cacheobject.ObjectResults{} 82 | 83 | cacheobject.CachableObject(&obj, &rv) 84 | cacheobject.ExpirationObject(&obj, &rv) 85 | 86 | fmt.Println("Errors: ", rv.OutErr) 87 | fmt.Println("Reasons to not cache: ", rv.OutReasons) 88 | fmt.Println("Warning headers to add: ", rv.OutWarnings) 89 | fmt.Println("Expiration: ", rv.OutExpirationTime.String()) 90 | } 91 | ``` 92 | 93 | ## Improvements, bugs, adding features, and taking cachecontrol new directions! 94 | 95 | Please [open issues in Github](https://github.com/pquerna/cachecontrol/issues) for ideas, bugs, and general thoughts. Pull requests are of course preferred :) 96 | 97 | # Credits 98 | 99 | `cachecontrol` has recieved significant contributions from: 100 | 101 | * [Paul Querna](https://github.com/pquerna) 102 | 103 | ## License 104 | 105 | `cachecontrol` is licensed under the [Apache License, Version 2.0](./LICENSE) 106 | -------------------------------------------------------------------------------- /api.go: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2015 Paul Querna 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | 18 | package cachecontrol 19 | 20 | import ( 21 | "github.com/pquerna/cachecontrol/cacheobject" 22 | 23 | "net/http" 24 | "time" 25 | ) 26 | 27 | type Options struct { 28 | // Set to True for a private cache, which is not shared among users (eg, in a browser) 29 | // Set to False for a "shared" cache, which is more common in a server context. 30 | PrivateCache bool 31 | } 32 | 33 | // Given an HTTP Request, the future Status Code, and an ResponseWriter, 34 | // determine the possible reasons a response SHOULD NOT be cached. 35 | func CachableResponseWriter(req *http.Request, 36 | statusCode int, 37 | resp http.ResponseWriter, 38 | opts Options) ([]cacheobject.Reason, time.Time, error) { 39 | return cacheobject.UsingRequestResponse(req, statusCode, resp.Header(), opts.PrivateCache) 40 | } 41 | 42 | // Given an HTTP Request and Response, determine the possible reasons a response SHOULD NOT 43 | // be cached. 44 | func CachableResponse(req *http.Request, 45 | resp *http.Response, 46 | opts Options) ([]cacheobject.Reason, time.Time, error) { 47 | return cacheobject.UsingRequestResponse(req, resp.StatusCode, resp.Header, opts.PrivateCache) 48 | } 49 | -------------------------------------------------------------------------------- /api_test.go: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2015 Paul Querna 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | 18 | package cachecontrol 19 | 20 | import ( 21 | "github.com/pquerna/cachecontrol/cacheobject" 22 | "github.com/stretchr/testify/require" 23 | 24 | "fmt" 25 | "io/ioutil" 26 | "net/http" 27 | "net/http/httptest" 28 | "testing" 29 | "time" 30 | ) 31 | 32 | func roundTrip(t *testing.T, fnc func(w http.ResponseWriter, r *http.Request)) (*http.Request, *http.Response) { 33 | ts := httptest.NewServer(http.HandlerFunc(fnc)) 34 | defer ts.Close() 35 | 36 | req, err := http.NewRequest("GET", ts.URL, nil) 37 | require.NoError(t, err) 38 | 39 | res, err := http.DefaultClient.Do(req) 40 | require.NoError(t, err) 41 | 42 | _, err = ioutil.ReadAll(res.Body) 43 | res.Body.Close() 44 | require.NoError(t, err) 45 | return req, res 46 | } 47 | 48 | func TestCachableResponsePublic(t *testing.T) { 49 | req, res := roundTrip(t, func(w http.ResponseWriter, r *http.Request) { 50 | w.Header().Set("Content-Type", "application/json") 51 | w.Header().Set("Cache-Control", "public") 52 | w.Header().Set("Last-Modified", 53 | time.Now().UTC().Add(time.Duration(time.Hour*-5)).Format(http.TimeFormat)) 54 | fmt.Fprintln(w, `{}`) 55 | }) 56 | 57 | opts := Options{} 58 | reasons, expires, err := CachableResponse(req, res, opts) 59 | require.NoError(t, err) 60 | require.Len(t, reasons, 0) 61 | require.WithinDuration(t, 62 | time.Now().UTC().Add(time.Duration(float64(time.Hour)*0.5)), 63 | expires, 64 | 10*time.Second) 65 | } 66 | 67 | func TestCachableResponsePrivate(t *testing.T) { 68 | req, res := roundTrip(t, func(w http.ResponseWriter, r *http.Request) { 69 | w.Header().Set("Content-Type", "application/json") 70 | w.Header().Set("Cache-Control", "private") 71 | fmt.Fprintln(w, `{}`) 72 | }) 73 | 74 | opts := Options{} 75 | reasons, expires, err := CachableResponse(req, res, opts) 76 | require.NoError(t, err) 77 | require.Len(t, reasons, 1) 78 | require.Equal(t, reasons[0], cacheobject.ReasonResponsePrivate) 79 | require.Equal(t, time.Time{}, expires) 80 | 81 | opts.PrivateCache = true 82 | reasons, expires, err = CachableResponse(req, res, opts) 83 | require.NoError(t, err) 84 | require.Len(t, reasons, 0) 85 | require.Equal(t, time.Time{}, expires) 86 | } 87 | 88 | func TestResponseWriter(t *testing.T) { 89 | var resp http.ResponseWriter 90 | var req *http.Request 91 | _, _ = roundTrip(t, func(w http.ResponseWriter, r *http.Request) { 92 | w.Header().Set("Content-Type", "application/json") 93 | w.Header().Set("Cache-Control", "private") 94 | fmt.Fprintln(w, `{}`) 95 | resp = w 96 | req = r 97 | }) 98 | 99 | opts := Options{} 100 | reasons, expires, err := CachableResponseWriter(req, 200, resp, opts) 101 | require.NoError(t, err) 102 | require.Len(t, reasons, 1) 103 | require.Equal(t, reasons[0], cacheobject.ReasonResponsePrivate) 104 | require.Equal(t, time.Time{}, expires) 105 | 106 | opts.PrivateCache = true 107 | reasons, expires, err = CachableResponseWriter(req, 200, resp, opts) 108 | require.NoError(t, err) 109 | require.Len(t, reasons, 0) 110 | require.Equal(t, time.Time{}, expires) 111 | } 112 | -------------------------------------------------------------------------------- /cacheobject/directive.go: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2015 Paul Querna 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | 18 | package cacheobject 19 | 20 | import ( 21 | "errors" 22 | "math" 23 | "net/http" 24 | "net/textproto" 25 | "strconv" 26 | "strings" 27 | ) 28 | 29 | // TODO(pquerna): add extensions from here: http://www.iana.org/assignments/http-cache-directives/http-cache-directives.xhtml 30 | 31 | var ( 32 | ErrQuoteMismatch = errors.New("Missing closing quote") 33 | ErrMaxAgeDeltaSeconds = errors.New("Failed to parse delta-seconds in `max-age`") 34 | ErrSMaxAgeDeltaSeconds = errors.New("Failed to parse delta-seconds in `s-maxage`") 35 | ErrMaxStaleDeltaSeconds = errors.New("Failed to parse delta-seconds in `max-stale`") 36 | ErrMinFreshDeltaSeconds = errors.New("Failed to parse delta-seconds in `min-fresh`") 37 | ErrNoCacheNoArgs = errors.New("Unexpected argument to `no-cache`") 38 | ErrNoStoreNoArgs = errors.New("Unexpected argument to `no-store`") 39 | ErrNoTransformNoArgs = errors.New("Unexpected argument to `no-transform`") 40 | ErrOnlyIfCachedNoArgs = errors.New("Unexpected argument to `only-if-cached`") 41 | ErrMustRevalidateNoArgs = errors.New("Unexpected argument to `must-revalidate`") 42 | ErrPublicNoArgs = errors.New("Unexpected argument to `public`") 43 | ErrProxyRevalidateNoArgs = errors.New("Unexpected argument to `proxy-revalidate`") 44 | // Experimental 45 | ErrImmutableNoArgs = errors.New("Unexpected argument to `immutable`") 46 | ErrStaleIfErrorDeltaSeconds = errors.New("Failed to parse delta-seconds in `stale-if-error`") 47 | ErrStaleWhileRevalidateDeltaSeconds = errors.New("Failed to parse delta-seconds in `stale-while-revalidate`") 48 | ) 49 | 50 | func whitespace(b byte) bool { 51 | if b == '\t' || b == ' ' { 52 | return true 53 | } 54 | return false 55 | } 56 | 57 | func parse(value string, cd cacheDirective) error { 58 | var err error = nil 59 | i := 0 60 | 61 | for i < len(value) && err == nil { 62 | // eat leading whitespace or commas 63 | if whitespace(value[i]) || value[i] == ',' { 64 | i++ 65 | continue 66 | } 67 | 68 | j := i + 1 69 | 70 | for j < len(value) { 71 | if !isToken(value[j]) { 72 | break 73 | } 74 | j++ 75 | } 76 | 77 | token := strings.ToLower(value[i:j]) 78 | tokenHasFields := hasFieldNames(token) 79 | /* 80 | println("GOT TOKEN:") 81 | println(" i -> ", i) 82 | println(" j -> ", j) 83 | println(" token -> ", token) 84 | */ 85 | 86 | if j+1 < len(value) && value[j] == '=' { 87 | k := j + 1 88 | // minimum size two bytes of "", but we let httpUnquote handle it. 89 | if k < len(value) && value[k] == '"' { 90 | eaten, result := httpUnquote(value[k:]) 91 | if eaten == -1 { 92 | return ErrQuoteMismatch 93 | } 94 | i = k + eaten 95 | 96 | err = cd.addPair(token, result) 97 | } else { 98 | z := k 99 | for z < len(value) { 100 | if tokenHasFields { 101 | if whitespace(value[z]) { 102 | break 103 | } 104 | } else { 105 | if whitespace(value[z]) || value[z] == ',' { 106 | break 107 | } 108 | } 109 | z++ 110 | } 111 | i = z 112 | 113 | result := value[k:z] 114 | if result != "" && result[len(result)-1] == ',' { 115 | result = result[:len(result)-1] 116 | } 117 | 118 | err = cd.addPair(token, result) 119 | } 120 | } else { 121 | if token != "," { 122 | err = cd.addToken(token) 123 | } 124 | i = j 125 | } 126 | } 127 | 128 | return err 129 | } 130 | 131 | // DeltaSeconds specifies a non-negative integer, representing 132 | // time in seconds: http://tools.ietf.org/html/rfc7234#section-1.2.1 133 | // 134 | // When set to -1, this means unset. 135 | type DeltaSeconds int32 136 | 137 | // Parser for delta-seconds, a uint31, more or less: 138 | // http://tools.ietf.org/html/rfc7234#section-1.2.1 139 | func parseDeltaSeconds(v string) (DeltaSeconds, error) { 140 | n, err := strconv.ParseUint(v, 10, 32) 141 | if err != nil { 142 | if numError, ok := err.(*strconv.NumError); ok { 143 | if numError.Err == strconv.ErrRange { 144 | return DeltaSeconds(math.MaxInt32), nil 145 | } 146 | } 147 | return DeltaSeconds(-1), err 148 | } else { 149 | if n > math.MaxInt32 { 150 | return DeltaSeconds(math.MaxInt32), nil 151 | } else { 152 | return DeltaSeconds(n), nil 153 | } 154 | } 155 | } 156 | 157 | // Fields present in a header. 158 | type FieldNames map[string]bool 159 | 160 | // internal interface for shared methods of RequestCacheDirectives and ResponseCacheDirectives 161 | type cacheDirective interface { 162 | addToken(s string) error 163 | addPair(s string, v string) error 164 | } 165 | 166 | // LOW LEVEL API: Representation of possible request directives in a `Cache-Control` header: http://tools.ietf.org/html/rfc7234#section-5.2.1 167 | // 168 | // Note: Many fields will be `nil` in practice. 169 | type RequestCacheDirectives struct { 170 | 171 | // max-age(delta seconds): http://tools.ietf.org/html/rfc7234#section-5.2.1.1 172 | // 173 | // The "max-age" request directive indicates that the client is 174 | // unwilling to accept a response whose age is greater than the 175 | // specified number of seconds. Unless the max-stale request directive 176 | // is also present, the client is not willing to accept a stale 177 | // response. 178 | MaxAge DeltaSeconds 179 | 180 | // max-stale(delta seconds): http://tools.ietf.org/html/rfc7234#section-5.2.1.2 181 | // 182 | // The "max-stale" request directive indicates that the client is 183 | // willing to accept a response that has exceeded its freshness 184 | // lifetime. If max-stale is assigned a value, then the client is 185 | // willing to accept a response that has exceeded its freshness lifetime 186 | // by no more than the specified number of seconds. If no value is 187 | // assigned to max-stale, then the client is willing to accept a stale 188 | // response of any age. 189 | MaxStale DeltaSeconds 190 | MaxStaleSet bool 191 | 192 | // min-fresh(delta seconds): http://tools.ietf.org/html/rfc7234#section-5.2.1.3 193 | // 194 | // The "min-fresh" request directive indicates that the client is 195 | // willing to accept a response whose freshness lifetime is no less than 196 | // its current age plus the specified time in seconds. That is, the 197 | // client wants a response that will still be fresh for at least the 198 | // specified number of seconds. 199 | MinFresh DeltaSeconds 200 | 201 | // no-cache(bool): http://tools.ietf.org/html/rfc7234#section-5.2.1.4 202 | // 203 | // The "no-cache" request directive indicates that a cache MUST NOT use 204 | // a stored response to satisfy the request without successful 205 | // validation on the origin server. 206 | NoCache bool 207 | 208 | // no-store(bool): http://tools.ietf.org/html/rfc7234#section-5.2.1.5 209 | // 210 | // The "no-store" request directive indicates that a cache MUST NOT 211 | // store any part of either this request or any response to it. This 212 | // directive applies to both private and shared caches. 213 | NoStore bool 214 | 215 | // no-transform(bool): http://tools.ietf.org/html/rfc7234#section-5.2.1.6 216 | // 217 | // The "no-transform" request directive indicates that an intermediary 218 | // (whether or not it implements a cache) MUST NOT transform the 219 | // payload, as defined in Section 5.7.2 of RFC7230. 220 | NoTransform bool 221 | 222 | // only-if-cached(bool): http://tools.ietf.org/html/rfc7234#section-5.2.1.7 223 | // 224 | // The "only-if-cached" request directive indicates that the client only 225 | // wishes to obtain a stored response. 226 | OnlyIfCached bool 227 | 228 | // stale-if-error(delta seconds): https://datatracker.ietf.org/doc/html/rfc5861#section-4 229 | StaleIfError DeltaSeconds 230 | 231 | // Extensions: http://tools.ietf.org/html/rfc7234#section-5.2.3 232 | // 233 | // The Cache-Control header field can be extended through the use of one 234 | // or more cache-extension tokens, each with an optional value. A cache 235 | // MUST ignore unrecognized cache directives. 236 | Extensions []string 237 | } 238 | 239 | func (cd *RequestCacheDirectives) addToken(token string) error { 240 | var err error = nil 241 | 242 | switch token { 243 | case "max-age": 244 | err = ErrMaxAgeDeltaSeconds 245 | case "min-fresh": 246 | err = ErrMinFreshDeltaSeconds 247 | case "max-stale": 248 | cd.MaxStaleSet = true 249 | case "no-cache": 250 | cd.NoCache = true 251 | case "no-store": 252 | cd.NoStore = true 253 | case "no-transform": 254 | cd.NoTransform = true 255 | case "only-if-cached": 256 | cd.OnlyIfCached = true 257 | case "stale-if-error": 258 | err = ErrMaxAgeDeltaSeconds 259 | default: 260 | cd.Extensions = append(cd.Extensions, token) 261 | } 262 | return err 263 | } 264 | 265 | func (cd *RequestCacheDirectives) addPair(token string, v string) error { 266 | var err error = nil 267 | 268 | switch token { 269 | case "max-age": 270 | cd.MaxAge, err = parseDeltaSeconds(v) 271 | if err != nil { 272 | err = ErrMaxAgeDeltaSeconds 273 | } 274 | case "max-stale": 275 | cd.MaxStale, err = parseDeltaSeconds(v) 276 | if err != nil { 277 | err = ErrMaxStaleDeltaSeconds 278 | } 279 | case "min-fresh": 280 | cd.MinFresh, err = parseDeltaSeconds(v) 281 | if err != nil { 282 | err = ErrMinFreshDeltaSeconds 283 | } 284 | case "no-cache": 285 | err = ErrNoCacheNoArgs 286 | case "no-store": 287 | err = ErrNoStoreNoArgs 288 | case "no-transform": 289 | err = ErrNoTransformNoArgs 290 | case "only-if-cached": 291 | err = ErrOnlyIfCachedNoArgs 292 | case "stale-if-error": 293 | cd.StaleIfError, err = parseDeltaSeconds(v) 294 | if err != nil { 295 | err = ErrStaleIfErrorDeltaSeconds 296 | } 297 | default: 298 | // TODO(pquerna): this sucks, making user re-parse 299 | cd.Extensions = append(cd.Extensions, token+"="+v) 300 | } 301 | 302 | return err 303 | } 304 | 305 | // LOW LEVEL API: Parses a Cache Control Header from a Request into a set of directives. 306 | func ParseRequestCacheControl(value string) (*RequestCacheDirectives, error) { 307 | cd := &RequestCacheDirectives{ 308 | MaxAge: -1, 309 | MaxStale: -1, 310 | MinFresh: -1, 311 | } 312 | 313 | err := parse(value, cd) 314 | if err != nil { 315 | return nil, err 316 | } 317 | return cd, nil 318 | } 319 | 320 | // LOW LEVEL API: Repersentation of possible response directives in a `Cache-Control` header: http://tools.ietf.org/html/rfc7234#section-5.2.2 321 | // 322 | // Note: Many fields will be `nil` in practice. 323 | type ResponseCacheDirectives struct { 324 | 325 | // must-revalidate(bool): http://tools.ietf.org/html/rfc7234#section-5.2.2.1 326 | // 327 | // The "must-revalidate" response directive indicates that once it has 328 | // become stale, a cache MUST NOT use the response to satisfy subsequent 329 | // requests without successful validation on the origin server. 330 | MustRevalidate bool 331 | 332 | // no-cache(FieldName): http://tools.ietf.org/html/rfc7234#section-5.2.2.2 333 | // 334 | // The "no-cache" response directive indicates that the response MUST 335 | // NOT be used to satisfy a subsequent request without successful 336 | // validation on the origin server. 337 | // 338 | // If the no-cache response directive specifies one or more field-names, 339 | // then a cache MAY use the response to satisfy a subsequent request, 340 | // subject to any other restrictions on caching. However, any header 341 | // fields in the response that have the field-name(s) listed MUST NOT be 342 | // sent in the response to a subsequent request without successful 343 | // revalidation with the origin server. 344 | NoCache FieldNames 345 | 346 | // no-cache(cast-to-bool): http://tools.ietf.org/html/rfc7234#section-5.2.2.2 347 | // 348 | // While the RFC defines optional field-names on a no-cache directive, 349 | // many applications only want to know if any no-cache directives were 350 | // present at all. 351 | NoCachePresent bool 352 | 353 | // no-store(bool): http://tools.ietf.org/html/rfc7234#section-5.2.2.3 354 | // 355 | // The "no-store" request directive indicates that a cache MUST NOT 356 | // store any part of either this request or any response to it. This 357 | // directive applies to both private and shared caches. 358 | NoStore bool 359 | 360 | // no-transform(bool): http://tools.ietf.org/html/rfc7234#section-5.2.2.4 361 | // 362 | // The "no-transform" response directive indicates that an intermediary 363 | // (regardless of whether it implements a cache) MUST NOT transform the 364 | // payload, as defined in Section 5.7.2 of RFC7230. 365 | NoTransform bool 366 | 367 | // public(bool): http://tools.ietf.org/html/rfc7234#section-5.2.2.5 368 | // 369 | // The "public" response directive indicates that any cache MAY store 370 | // the response, even if the response would normally be non-cacheable or 371 | // cacheable only within a private cache. 372 | Public bool 373 | 374 | // private(FieldName): http://tools.ietf.org/html/rfc7234#section-5.2.2.6 375 | // 376 | // The "private" response directive indicates that the response message 377 | // is intended for a single user and MUST NOT be stored by a shared 378 | // cache. A private cache MAY store the response and reuse it for later 379 | // requests, even if the response would normally be non-cacheable. 380 | // 381 | // If the private response directive specifies one or more field-names, 382 | // this requirement is limited to the field-values associated with the 383 | // listed response header fields. That is, a shared cache MUST NOT 384 | // store the specified field-names(s), whereas it MAY store the 385 | // remainder of the response message. 386 | Private FieldNames 387 | 388 | // private(cast-to-bool): http://tools.ietf.org/html/rfc7234#section-5.2.2.6 389 | // 390 | // While the RFC defines optional field-names on a private directive, 391 | // many applications only want to know if any private directives were 392 | // present at all. 393 | PrivatePresent bool 394 | 395 | // proxy-revalidate(bool): http://tools.ietf.org/html/rfc7234#section-5.2.2.7 396 | // 397 | // The "proxy-revalidate" response directive has the same meaning as the 398 | // must-revalidate response directive, except that it does not apply to 399 | // private caches. 400 | ProxyRevalidate bool 401 | 402 | // max-age(delta seconds): http://tools.ietf.org/html/rfc7234#section-5.2.2.8 403 | // 404 | // The "max-age" response directive indicates that the response is to be 405 | // considered stale after its age is greater than the specified number 406 | // of seconds. 407 | MaxAge DeltaSeconds 408 | 409 | // s-maxage(delta seconds): http://tools.ietf.org/html/rfc7234#section-5.2.2.9 410 | // 411 | // The "s-maxage" response directive indicates that, in shared caches, 412 | // the maximum age specified by this directive overrides the maximum age 413 | // specified by either the max-age directive or the Expires header 414 | // field. The s-maxage directive also implies the semantics of the 415 | // proxy-revalidate response directive. 416 | SMaxAge DeltaSeconds 417 | 418 | //// 419 | // Experimental features 420 | // - https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#Extension_Cache-Control_directives 421 | // - https://www.fastly.com/blog/stale-while-revalidate-stale-if-error-available-today 422 | //// 423 | 424 | // immutable(cast-to-bool): experimental feature 425 | Immutable bool 426 | 427 | // stale-if-error(delta seconds): experimental feature 428 | StaleIfError DeltaSeconds 429 | 430 | // stale-while-revalidate(delta seconds): experimental feature 431 | StaleWhileRevalidate DeltaSeconds 432 | 433 | // Extensions: http://tools.ietf.org/html/rfc7234#section-5.2.3 434 | // 435 | // The Cache-Control header field can be extended through the use of one 436 | // or more cache-extension tokens, each with an optional value. A cache 437 | // MUST ignore unrecognized cache directives. 438 | Extensions []string 439 | } 440 | 441 | // LOW LEVEL API: Parses a Cache Control Header from a Response into a set of directives. 442 | func ParseResponseCacheControl(value string) (*ResponseCacheDirectives, error) { 443 | cd := &ResponseCacheDirectives{ 444 | MaxAge: -1, 445 | SMaxAge: -1, 446 | // Exerimantal stale timeouts 447 | StaleIfError: -1, 448 | StaleWhileRevalidate: -1, 449 | } 450 | 451 | err := parse(value, cd) 452 | if err != nil { 453 | return nil, err 454 | } 455 | return cd, nil 456 | } 457 | 458 | func (cd *ResponseCacheDirectives) addToken(token string) error { 459 | var err error = nil 460 | switch token { 461 | case "must-revalidate": 462 | cd.MustRevalidate = true 463 | case "no-cache": 464 | cd.NoCachePresent = true 465 | case "no-store": 466 | cd.NoStore = true 467 | case "no-transform": 468 | cd.NoTransform = true 469 | case "public": 470 | cd.Public = true 471 | case "private": 472 | cd.PrivatePresent = true 473 | case "proxy-revalidate": 474 | cd.ProxyRevalidate = true 475 | case "max-age": 476 | err = ErrMaxAgeDeltaSeconds 477 | case "s-maxage": 478 | err = ErrSMaxAgeDeltaSeconds 479 | // Experimental 480 | case "immutable": 481 | cd.Immutable = true 482 | default: 483 | cd.Extensions = append(cd.Extensions, token) 484 | } 485 | return err 486 | } 487 | 488 | func hasFieldNames(token string) bool { 489 | switch token { 490 | case "no-cache": 491 | return true 492 | case "private": 493 | return true 494 | } 495 | return false 496 | } 497 | 498 | func (cd *ResponseCacheDirectives) addPair(token string, v string) error { 499 | var err error = nil 500 | 501 | switch token { 502 | case "must-revalidate": 503 | err = ErrMustRevalidateNoArgs 504 | case "no-cache": 505 | cd.NoCachePresent = true 506 | tokens := strings.Split(v, ",") 507 | if cd.NoCache == nil { 508 | cd.NoCache = make(FieldNames) 509 | } 510 | for _, t := range tokens { 511 | k := http.CanonicalHeaderKey(textproto.TrimString(t)) 512 | cd.NoCache[k] = true 513 | } 514 | case "no-store": 515 | err = ErrNoStoreNoArgs 516 | case "no-transform": 517 | err = ErrNoTransformNoArgs 518 | case "public": 519 | err = ErrPublicNoArgs 520 | case "private": 521 | cd.PrivatePresent = true 522 | tokens := strings.Split(v, ",") 523 | if cd.Private == nil { 524 | cd.Private = make(FieldNames) 525 | } 526 | for _, t := range tokens { 527 | k := http.CanonicalHeaderKey(textproto.TrimString(t)) 528 | cd.Private[k] = true 529 | } 530 | case "proxy-revalidate": 531 | err = ErrProxyRevalidateNoArgs 532 | case "max-age": 533 | cd.MaxAge, err = parseDeltaSeconds(v) 534 | case "s-maxage": 535 | cd.SMaxAge, err = parseDeltaSeconds(v) 536 | // Experimental 537 | case "immutable": 538 | err = ErrImmutableNoArgs 539 | case "stale-if-error": 540 | cd.StaleIfError, err = parseDeltaSeconds(v) 541 | case "stale-while-revalidate": 542 | cd.StaleWhileRevalidate, err = parseDeltaSeconds(v) 543 | default: 544 | // TODO(pquerna): this sucks, making user re-parse, and its technically not 'quoted' like the original, 545 | // but this is still easier, just a SplitN on "=" 546 | cd.Extensions = append(cd.Extensions, token+"="+v) 547 | } 548 | 549 | return err 550 | } 551 | -------------------------------------------------------------------------------- /cacheobject/directive_test.go: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2015 Paul Querna 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | 18 | package cacheobject 19 | 20 | import ( 21 | "github.com/stretchr/testify/assert" 22 | "github.com/stretchr/testify/require" 23 | 24 | "fmt" 25 | "math" 26 | "testing" 27 | ) 28 | 29 | func TestMaxAge(t *testing.T) { 30 | cd, err := ParseResponseCacheControl("") 31 | require.NoError(t, err) 32 | require.Equal(t, cd.MaxAge, DeltaSeconds(-1)) 33 | 34 | _, err = ParseResponseCacheControl("max-age") 35 | require.Error(t, err) 36 | 37 | cd, err = ParseResponseCacheControl("max-age=20") 38 | require.NoError(t, err) 39 | require.Equal(t, cd.MaxAge, DeltaSeconds(20)) 40 | 41 | cd, err = ParseResponseCacheControl("max-age=0") 42 | require.NoError(t, err) 43 | require.Equal(t, cd.MaxAge, DeltaSeconds(0)) 44 | 45 | _, err = ParseResponseCacheControl("max-age=-1") 46 | require.Error(t, err) 47 | } 48 | 49 | func TestSMaxAge(t *testing.T) { 50 | cd, err := ParseResponseCacheControl("") 51 | require.NoError(t, err) 52 | require.Equal(t, cd.SMaxAge, DeltaSeconds(-1)) 53 | 54 | _, err = ParseResponseCacheControl("s-maxage") 55 | require.Error(t, err) 56 | 57 | cd, err = ParseResponseCacheControl("s-maxage=20") 58 | require.NoError(t, err) 59 | require.Equal(t, cd.SMaxAge, DeltaSeconds(20)) 60 | 61 | cd, err = ParseResponseCacheControl("s-maxage=0") 62 | require.NoError(t, err) 63 | require.Equal(t, cd.SMaxAge, DeltaSeconds(0)) 64 | 65 | _, err = ParseResponseCacheControl("s-maxage=-1") 66 | require.Error(t, err) 67 | } 68 | 69 | func TestResNoCache(t *testing.T) { 70 | cd, err := ParseResponseCacheControl("") 71 | require.NoError(t, err) 72 | require.Equal(t, cd.SMaxAge, DeltaSeconds(-1)) 73 | 74 | cd, err = ParseResponseCacheControl("no-cache") 75 | require.NoError(t, err) 76 | require.Equal(t, cd.NoCachePresent, true) 77 | require.Equal(t, len(cd.NoCache), 0) 78 | 79 | cd, err = ParseResponseCacheControl("no-cache=MyThing") 80 | require.NoError(t, err) 81 | require.Equal(t, cd.NoCachePresent, true) 82 | require.Equal(t, len(cd.NoCache), 1) 83 | } 84 | 85 | func TestResSpaceOnly(t *testing.T) { 86 | cd, err := ParseResponseCacheControl(" ") 87 | require.NoError(t, err) 88 | require.Equal(t, cd.SMaxAge, DeltaSeconds(-1)) 89 | } 90 | 91 | func TestResTabOnly(t *testing.T) { 92 | cd, err := ParseResponseCacheControl("\t") 93 | require.NoError(t, err) 94 | require.Equal(t, cd.SMaxAge, DeltaSeconds(-1)) 95 | } 96 | 97 | func TestResPrivateExtensionQuoted(t *testing.T) { 98 | cd, err := ParseResponseCacheControl(`private="Set-Cookie,Request-Id" public`) 99 | require.NoError(t, err) 100 | require.Equal(t, cd.Public, true) 101 | require.Equal(t, cd.PrivatePresent, true) 102 | require.Equal(t, len(cd.Private), 2) 103 | require.Equal(t, len(cd.Extensions), 0) 104 | require.Equal(t, cd.Private["Set-Cookie"], true) 105 | require.Equal(t, cd.Private["Request-Id"], true) 106 | } 107 | 108 | func TestResCommaFollowingBare(t *testing.T) { 109 | cd, err := ParseResponseCacheControl(`public, max-age=500`) 110 | require.NoError(t, err) 111 | require.Equal(t, cd.Public, true) 112 | require.Equal(t, cd.MaxAge, DeltaSeconds(500)) 113 | require.Equal(t, cd.PrivatePresent, false) 114 | require.Equal(t, len(cd.Extensions), 0) 115 | } 116 | 117 | func TestResCommaFollowingKV(t *testing.T) { 118 | cd, err := ParseResponseCacheControl(`max-age=500, public`) 119 | require.NoError(t, err) 120 | require.Equal(t, cd.Public, true) 121 | require.Equal(t, cd.MaxAge, DeltaSeconds(500)) 122 | require.Equal(t, cd.PrivatePresent, false) 123 | require.Equal(t, len(cd.Extensions), 0) 124 | } 125 | 126 | func TestResPrivateTrailingComma(t *testing.T) { 127 | cd, err := ParseResponseCacheControl(`private=Set-Cookie, public`) 128 | require.NoError(t, err) 129 | require.Equal(t, cd.Public, true) 130 | require.Equal(t, cd.PrivatePresent, true) 131 | require.Equal(t, len(cd.Private), 1) 132 | require.Equal(t, len(cd.Extensions), 0) 133 | require.Equal(t, cd.Private["Set-Cookie"], true) 134 | } 135 | 136 | func TestResPrivateExtension(t *testing.T) { 137 | cd, err := ParseResponseCacheControl(`private=Set-Cookie,Request-Id public`) 138 | require.NoError(t, err) 139 | require.Equal(t, cd.Public, true) 140 | require.Equal(t, cd.PrivatePresent, true) 141 | require.Equal(t, len(cd.Private), 2) 142 | require.Equal(t, len(cd.Extensions), 0) 143 | require.Equal(t, cd.Private["Set-Cookie"], true) 144 | require.Equal(t, cd.Private["Request-Id"], true) 145 | } 146 | 147 | func TestResMultipleNoCacheTabExtension(t *testing.T) { 148 | cd, err := ParseResponseCacheControl("no-cache " + "\t" + "no-cache=Mything aasdfdsfa") 149 | require.NoError(t, err) 150 | require.Equal(t, cd.NoCachePresent, true) 151 | require.Equal(t, len(cd.NoCache), 1) 152 | require.Equal(t, len(cd.Extensions), 1) 153 | require.Equal(t, cd.NoCache["Mything"], true) 154 | } 155 | 156 | func TestResExtensionsEmptyQuote(t *testing.T) { 157 | cd, err := ParseResponseCacheControl(`foo="" bar="hi"`) 158 | require.NoError(t, err) 159 | require.Equal(t, cd.SMaxAge, DeltaSeconds(-1)) 160 | require.Equal(t, len(cd.Extensions), 2) 161 | require.Contains(t, cd.Extensions, "bar=hi") 162 | require.Contains(t, cd.Extensions, "foo=") 163 | } 164 | 165 | func TestResQuoteMismatch(t *testing.T) { 166 | cd, err := ParseResponseCacheControl(`foo="`) 167 | require.Error(t, err) 168 | require.Nil(t, cd) 169 | require.Equal(t, err, ErrQuoteMismatch) 170 | } 171 | 172 | func TestResMustRevalidateNoArgs(t *testing.T) { 173 | cd, err := ParseResponseCacheControl(`must-revalidate=234`) 174 | require.Error(t, err) 175 | require.Nil(t, cd) 176 | require.Equal(t, err, ErrMustRevalidateNoArgs) 177 | } 178 | 179 | func TestResNoTransformNoArgs(t *testing.T) { 180 | cd, err := ParseResponseCacheControl(`no-transform="xxx"`) 181 | require.Error(t, err) 182 | require.Nil(t, cd) 183 | require.Equal(t, err, ErrNoTransformNoArgs) 184 | } 185 | 186 | func TestResNoStoreNoArgs(t *testing.T) { 187 | cd, err := ParseResponseCacheControl(`no-store=""`) 188 | require.Error(t, err) 189 | require.Nil(t, cd) 190 | require.Equal(t, err, ErrNoStoreNoArgs) 191 | } 192 | 193 | func TestResProxyRevalidateNoArgs(t *testing.T) { 194 | cd, err := ParseResponseCacheControl(`proxy-revalidate=23432`) 195 | require.Error(t, err) 196 | require.Nil(t, cd) 197 | require.Equal(t, err, ErrProxyRevalidateNoArgs) 198 | } 199 | 200 | func TestResPublicNoArgs(t *testing.T) { 201 | cd, err := ParseResponseCacheControl(`public=999Vary`) 202 | require.Error(t, err) 203 | require.Nil(t, cd) 204 | require.Equal(t, err, ErrPublicNoArgs) 205 | } 206 | 207 | func TestResMustRevalidate(t *testing.T) { 208 | cd, err := ParseResponseCacheControl(`must-revalidate`) 209 | require.NoError(t, err) 210 | require.NotNil(t, cd) 211 | require.Equal(t, cd.MustRevalidate, true) 212 | } 213 | 214 | func TestResNoTransform(t *testing.T) { 215 | cd, err := ParseResponseCacheControl(`no-transform`) 216 | require.NoError(t, err) 217 | require.NotNil(t, cd) 218 | require.Equal(t, cd.NoTransform, true) 219 | } 220 | 221 | func TestResNoStore(t *testing.T) { 222 | cd, err := ParseResponseCacheControl(`no-store`) 223 | require.NoError(t, err) 224 | require.NotNil(t, cd) 225 | require.Equal(t, cd.NoStore, true) 226 | } 227 | 228 | func TestResProxyRevalidate(t *testing.T) { 229 | cd, err := ParseResponseCacheControl(`proxy-revalidate`) 230 | require.NoError(t, err) 231 | require.NotNil(t, cd) 232 | require.Equal(t, cd.ProxyRevalidate, true) 233 | } 234 | 235 | func TestResPublic(t *testing.T) { 236 | cd, err := ParseResponseCacheControl(`public`) 237 | require.NoError(t, err) 238 | require.NotNil(t, cd) 239 | require.Equal(t, cd.Public, true) 240 | } 241 | 242 | func TestResPrivate(t *testing.T) { 243 | cd, err := ParseResponseCacheControl(`private`) 244 | require.NoError(t, err) 245 | require.NotNil(t, cd) 246 | require.Len(t, cd.Private, 0) 247 | require.Equal(t, cd.PrivatePresent, true) 248 | } 249 | 250 | func TestResImmutable(t *testing.T) { 251 | cd, err := ParseResponseCacheControl(`immutable`) 252 | require.NoError(t, err) 253 | require.NotNil(t, cd) 254 | require.Equal(t, cd.Immutable, true) 255 | } 256 | 257 | func TestResStaleIfError(t *testing.T) { 258 | cd, err := ParseResponseCacheControl(`stale-if-error=99999`) 259 | require.NoError(t, err) 260 | require.NotNil(t, cd) 261 | require.Equal(t, cd.StaleIfError, DeltaSeconds(99999)) 262 | } 263 | 264 | func TestResStaleIfErrorBare(t *testing.T) { 265 | cd, err := ParseResponseCacheControl(`stale-if-error`) 266 | require.NoError(t, err) 267 | require.NotNil(t, cd) 268 | 269 | // `stale-if-error` without value is treated like an extension directive 270 | require.Equal(t, cd.StaleIfError, -1) 271 | assert.Contains(t, cd.Extensions, "stale-if-error") 272 | } 273 | 274 | func TestResStaleWhileRevalidate(t *testing.T) { 275 | cd, err := ParseResponseCacheControl(`stale-while-revalidate=99999`) 276 | require.NoError(t, err) 277 | require.NotNil(t, cd) 278 | require.Equal(t, cd.StaleWhileRevalidate, DeltaSeconds(99999)) 279 | } 280 | 281 | func TestResStaleWhileRevalidateBare(t *testing.T) { 282 | cd, err := ParseResponseCacheControl(`stale-while-revalidate`) 283 | require.NoError(t, err) 284 | require.NotNil(t, cd) 285 | 286 | // bare `stale-while-revalidate` is treated like an extension directive 287 | require.EqualValues(t, cd.StaleWhileRevalidate, -1) 288 | assert.Contains(t, cd.Extensions, "stale-while-revalidate") 289 | } 290 | 291 | func TestParseDeltaSecondsZero(t *testing.T) { 292 | ds, err := parseDeltaSeconds("0") 293 | require.NoError(t, err) 294 | require.Equal(t, ds, DeltaSeconds(0)) 295 | } 296 | 297 | func TestParseDeltaSecondsLarge(t *testing.T) { 298 | ds, err := parseDeltaSeconds(fmt.Sprintf("%d", int64(math.MaxInt32)*2)) 299 | require.NoError(t, err) 300 | require.Equal(t, ds, DeltaSeconds(math.MaxInt32)) 301 | } 302 | 303 | func TestParseDeltaSecondsVeryLarge(t *testing.T) { 304 | ds, err := parseDeltaSeconds(fmt.Sprintf("%d", int64(math.MaxInt64))) 305 | require.NoError(t, err) 306 | require.Equal(t, ds, DeltaSeconds(math.MaxInt32)) 307 | } 308 | 309 | func TestParseDeltaSecondsNegative(t *testing.T) { 310 | ds, err := parseDeltaSeconds("-60") 311 | require.Error(t, err) 312 | require.Equal(t, DeltaSeconds(-1), ds) 313 | } 314 | 315 | func TestReqNoCacheNoArgs(t *testing.T) { 316 | cd, err := ParseRequestCacheControl(`no-cache=234`) 317 | require.Error(t, err) 318 | require.Nil(t, cd) 319 | require.Equal(t, err, ErrNoCacheNoArgs) 320 | } 321 | 322 | func TestReqNoStoreNoArgs(t *testing.T) { 323 | cd, err := ParseRequestCacheControl(`no-store=,,x`) 324 | require.Error(t, err) 325 | require.Nil(t, cd) 326 | require.Equal(t, err, ErrNoStoreNoArgs) 327 | } 328 | 329 | func TestReqNoTransformNoArgs(t *testing.T) { 330 | cd, err := ParseRequestCacheControl(`no-transform=akx`) 331 | require.Error(t, err) 332 | require.Nil(t, cd) 333 | require.Equal(t, err, ErrNoTransformNoArgs) 334 | } 335 | 336 | func TestReqOnlyIfCachedNoArgs(t *testing.T) { 337 | cd, err := ParseRequestCacheControl(`only-if-cached=no-store`) 338 | require.Error(t, err) 339 | require.Nil(t, cd) 340 | require.Equal(t, err, ErrOnlyIfCachedNoArgs) 341 | } 342 | 343 | func TestReqMaxAge(t *testing.T) { 344 | cd, err := ParseRequestCacheControl(`max-age=99999`) 345 | require.NoError(t, err) 346 | require.NotNil(t, cd) 347 | require.Equal(t, cd.MaxAge, DeltaSeconds(99999)) 348 | require.Equal(t, cd.MaxStale, DeltaSeconds(-1)) 349 | } 350 | 351 | func TestReqMaxStale(t *testing.T) { 352 | cd, err := ParseRequestCacheControl(`max-stale`) 353 | require.NoError(t, err) 354 | require.NotNil(t, cd) 355 | require.True(t, cd.MaxStaleSet) 356 | require.Equal(t, cd.MaxStale, DeltaSeconds(-1)) 357 | require.Equal(t, cd.MaxAge, DeltaSeconds(-1)) 358 | require.Equal(t, cd.MinFresh, DeltaSeconds(-1)) 359 | 360 | cd, err = ParseRequestCacheControl(`max-stale=99999`) 361 | require.NoError(t, err) 362 | require.NotNil(t, cd) 363 | require.Equal(t, cd.MaxStale, DeltaSeconds(99999)) 364 | require.Equal(t, cd.MaxAge, DeltaSeconds(-1)) 365 | require.Equal(t, cd.MinFresh, DeltaSeconds(-1)) 366 | } 367 | 368 | func TestReqMaxAgeBroken(t *testing.T) { 369 | cd, err := ParseRequestCacheControl(`max-age`) 370 | require.Error(t, err) 371 | require.Equal(t, ErrMaxAgeDeltaSeconds, err) 372 | require.Nil(t, cd) 373 | } 374 | 375 | func TestReqMinFresh(t *testing.T) { 376 | cd, err := ParseRequestCacheControl(`min-fresh=99999`) 377 | require.NoError(t, err) 378 | require.NotNil(t, cd) 379 | require.Equal(t, cd.MinFresh, DeltaSeconds(99999)) 380 | require.Equal(t, cd.MaxAge, DeltaSeconds(-1)) 381 | require.Equal(t, cd.MaxStale, DeltaSeconds(-1)) 382 | } 383 | 384 | func TestReqMinFreshBroken(t *testing.T) { 385 | cd, err := ParseRequestCacheControl(`min-fresh`) 386 | require.Error(t, err) 387 | require.Equal(t, ErrMinFreshDeltaSeconds, err) 388 | require.Nil(t, cd) 389 | } 390 | 391 | func TestReqMinFreshJunk(t *testing.T) { 392 | cd, err := ParseRequestCacheControl(`min-fresh=a99a`) 393 | require.Equal(t, ErrMinFreshDeltaSeconds, err) 394 | require.Nil(t, cd) 395 | } 396 | 397 | func TestReqMinFreshBadValue(t *testing.T) { 398 | cd, err := ParseRequestCacheControl(`min-fresh=-1`) 399 | require.Equal(t, ErrMinFreshDeltaSeconds, err) 400 | require.Nil(t, cd) 401 | } 402 | 403 | func TestReqExtensions(t *testing.T) { 404 | cd, err := ParseRequestCacheControl(`min-fresh=99999 foobar=1 cats`) 405 | require.NoError(t, err) 406 | require.NotNil(t, cd) 407 | require.Equal(t, cd.MinFresh, DeltaSeconds(99999)) 408 | require.Equal(t, cd.MaxAge, DeltaSeconds(-1)) 409 | require.Equal(t, cd.MaxStale, DeltaSeconds(-1)) 410 | require.Len(t, cd.Extensions, 2) 411 | require.Contains(t, cd.Extensions, "foobar=1") 412 | require.Contains(t, cd.Extensions, "cats") 413 | } 414 | 415 | func TestReqMultiple(t *testing.T) { 416 | cd, err := ParseRequestCacheControl(`no-store no-transform`) 417 | require.NoError(t, err) 418 | require.NotNil(t, cd) 419 | require.Equal(t, cd.NoStore, true) 420 | require.Equal(t, cd.NoTransform, true) 421 | require.Equal(t, cd.OnlyIfCached, false) 422 | require.Len(t, cd.Extensions, 0) 423 | } 424 | 425 | func TestReqMultipleComma(t *testing.T) { 426 | cd, err := ParseRequestCacheControl(`no-cache,only-if-cached`) 427 | require.NoError(t, err) 428 | require.NotNil(t, cd) 429 | require.Equal(t, cd.NoCache, true) 430 | require.Equal(t, cd.NoStore, false) 431 | require.Equal(t, cd.NoTransform, false) 432 | require.Equal(t, cd.OnlyIfCached, true) 433 | require.Len(t, cd.Extensions, 0) 434 | } 435 | 436 | func TestReqLeadingComma(t *testing.T) { 437 | cd, err := ParseRequestCacheControl(`,no-cache`) 438 | require.NoError(t, err) 439 | require.NotNil(t, cd) 440 | require.Len(t, cd.Extensions, 0) 441 | require.Equal(t, cd.NoCache, true) 442 | require.Equal(t, cd.NoStore, false) 443 | require.Equal(t, cd.NoTransform, false) 444 | require.Equal(t, cd.OnlyIfCached, false) 445 | } 446 | 447 | func TestReqMinFreshQuoted(t *testing.T) { 448 | cd, err := ParseRequestCacheControl(`min-fresh="99999"`) 449 | require.NoError(t, err) 450 | require.NotNil(t, cd) 451 | require.Equal(t, cd.MinFresh, DeltaSeconds(99999)) 452 | require.Equal(t, cd.MaxAge, DeltaSeconds(-1)) 453 | require.Equal(t, cd.MaxStale, DeltaSeconds(-1)) 454 | } 455 | 456 | func TestNoSpacesIssue3(t *testing.T) { 457 | cd, err := ParseResponseCacheControl(`no-cache,no-store,max-age=0,must-revalidate`) 458 | require.NoError(t, err) 459 | require.NotNil(t, cd) 460 | require.Equal(t, cd.NoCachePresent, true) 461 | require.Equal(t, cd.NoStore, true) 462 | require.Equal(t, cd.MaxAge, DeltaSeconds(0)) 463 | require.Equal(t, cd.MustRevalidate, true) 464 | } 465 | 466 | func TestStaleIfError(t *testing.T) { 467 | cd, err := ParseRequestCacheControl(`stale-if-error=999`) 468 | require.NoError(t, err) 469 | require.NotNil(t, cd) 470 | require.Equal(t, cd.StaleIfError, DeltaSeconds(999)) 471 | } 472 | 473 | func TestNoSpacesIssue3PrivateFields(t *testing.T) { 474 | cd, err := ParseResponseCacheControl(`no-cache, no-store, private=set-cookie,hello, max-age=0, must-revalidate`) 475 | require.NoError(t, err) 476 | require.NotNil(t, cd) 477 | require.Equal(t, cd.NoCachePresent, true) 478 | require.Equal(t, cd.NoStore, true) 479 | require.Equal(t, cd.MaxAge, DeltaSeconds(0)) 480 | require.Equal(t, cd.MustRevalidate, true) 481 | require.Equal(t, true, cd.Private["Set-Cookie"]) 482 | require.Equal(t, true, cd.Private["Hello"]) 483 | } 484 | -------------------------------------------------------------------------------- /cacheobject/lex.go: -------------------------------------------------------------------------------- 1 | // Copyright 2009 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package cacheobject 6 | 7 | // This file deals with lexical matters of HTTP 8 | 9 | func isSeparator(c byte) bool { 10 | switch c { 11 | case '(', ')', '<', '>', '@', ',', ';', ':', '\\', '"', '/', '[', ']', '?', '=', '{', '}', ' ', '\t': 12 | return true 13 | } 14 | return false 15 | } 16 | 17 | func isCtl(c byte) bool { return (0 <= c && c <= 31) || c == 127 } 18 | 19 | func isChar(c byte) bool { return 0 <= c && c <= 127 } 20 | 21 | func isAnyText(c byte) bool { return !isCtl(c) } 22 | 23 | func isQdText(c byte) bool { return isAnyText(c) && c != '"' } 24 | 25 | func isToken(c byte) bool { return isChar(c) && !isCtl(c) && !isSeparator(c) } 26 | 27 | // Valid escaped sequences are not specified in RFC 2616, so for now, we assume 28 | // that they coincide with the common sense ones used by GO. Malformed 29 | // characters should probably not be treated as errors by a robust (forgiving) 30 | // parser, so we replace them with the '?' character. 31 | func httpUnquotePair(b byte) byte { 32 | // skip the first byte, which should always be '\' 33 | switch b { 34 | case 'a': 35 | return '\a' 36 | case 'b': 37 | return '\b' 38 | case 'f': 39 | return '\f' 40 | case 'n': 41 | return '\n' 42 | case 'r': 43 | return '\r' 44 | case 't': 45 | return '\t' 46 | case 'v': 47 | return '\v' 48 | case '\\': 49 | return '\\' 50 | case '\'': 51 | return '\'' 52 | case '"': 53 | return '"' 54 | } 55 | return '?' 56 | } 57 | 58 | // raw must begin with a valid quoted string. Only the first quoted string is 59 | // parsed and is unquoted in result. eaten is the number of bytes parsed, or -1 60 | // upon failure. 61 | func httpUnquote(raw string) (eaten int, result string) { 62 | buf := make([]byte, len(raw)) 63 | if raw[0] != '"' { 64 | return -1, "" 65 | } 66 | eaten = 1 67 | j := 0 // # of bytes written in buf 68 | for i := 1; i < len(raw); i++ { 69 | switch b := raw[i]; b { 70 | case '"': 71 | eaten++ 72 | buf = buf[0:j] 73 | return i + 1, string(buf) 74 | case '\\': 75 | if len(raw) < i+2 { 76 | return -1, "" 77 | } 78 | buf[j] = httpUnquotePair(raw[i+1]) 79 | eaten += 2 80 | j++ 81 | i++ 82 | default: 83 | if isQdText(b) { 84 | buf[j] = b 85 | } else { 86 | buf[j] = '?' 87 | } 88 | eaten++ 89 | j++ 90 | } 91 | } 92 | return -1, "" 93 | } 94 | -------------------------------------------------------------------------------- /cacheobject/object.go: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2015 Paul Querna 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | 18 | package cacheobject 19 | 20 | import ( 21 | "net/http" 22 | "time" 23 | ) 24 | 25 | // LOW LEVEL API: Represents a potentially cachable HTTP object. 26 | // 27 | // This struct is designed to be serialized efficiently, so in a high 28 | // performance caching server, things like Date-Strings don't need to be 29 | // parsed for every use of a cached object. 30 | type Object struct { 31 | CacheIsPrivate bool 32 | 33 | RespDirectives *ResponseCacheDirectives 34 | RespHeaders http.Header 35 | RespStatusCode int 36 | RespExpiresHeader time.Time 37 | RespDateHeader time.Time 38 | RespLastModifiedHeader time.Time 39 | 40 | ReqDirectives *RequestCacheDirectives 41 | ReqHeaders http.Header 42 | ReqMethod string 43 | 44 | NowUTC time.Time 45 | } 46 | 47 | // LOW LEVEL API: Represents the results of examining an Object with 48 | // CachableObject and ExpirationObject. 49 | // 50 | // TODO(pquerna): decide if this is a good idea or bad 51 | type ObjectResults struct { 52 | OutReasons []Reason 53 | OutWarnings []Warning 54 | OutExpirationTime time.Time 55 | OutErr error 56 | } 57 | 58 | // LOW LEVEL API: Check if a request is cacheable. 59 | // This function doesn't reset the passed ObjectResults. 60 | func CachableRequestObject(obj *Object, rv *ObjectResults) { 61 | switch obj.ReqMethod { 62 | case "GET": 63 | break 64 | case "HEAD": 65 | break 66 | case "POST": 67 | // Responses to POST requests can be cacheable if they include explicit freshness information 68 | break 69 | 70 | case "PUT": 71 | rv.OutReasons = append(rv.OutReasons, ReasonRequestMethodPUT) 72 | 73 | case "DELETE": 74 | rv.OutReasons = append(rv.OutReasons, ReasonRequestMethodDELETE) 75 | 76 | case "CONNECT": 77 | rv.OutReasons = append(rv.OutReasons, ReasonRequestMethodCONNECT) 78 | 79 | case "OPTIONS": 80 | rv.OutReasons = append(rv.OutReasons, ReasonRequestMethodOPTIONS) 81 | 82 | case "TRACE": 83 | rv.OutReasons = append(rv.OutReasons, ReasonRequestMethodTRACE) 84 | 85 | // HTTP Extension Methods: http://www.iana.org/assignments/http-methods/http-methods.xhtml 86 | // 87 | // To my knowledge, none of them are cachable. Please open a ticket if this is not the case! 88 | // 89 | default: 90 | rv.OutReasons = append(rv.OutReasons, ReasonRequestMethodUnknown) 91 | } 92 | 93 | if obj.ReqDirectives != nil && obj.ReqDirectives.NoStore { 94 | rv.OutReasons = append(rv.OutReasons, ReasonRequestNoStore) 95 | } 96 | } 97 | 98 | // LOW LEVEL API: Check if a response is cacheable. 99 | // This function doesn't reset the passed ObjectResults. 100 | func CachableResponseObject(obj *Object, rv *ObjectResults) { 101 | /** 102 | POST: http://tools.ietf.org/html/rfc7231#section-4.3.3 103 | 104 | Responses to POST requests are only cacheable when they include 105 | explicit freshness information (see Section 4.2.1 of [RFC7234]). 106 | However, POST caching is not widely implemented. For cases where an 107 | origin server wishes the client to be able to cache the result of a 108 | POST in a way that can be reused by a later GET, the origin server 109 | MAY send a 200 (OK) response containing the result and a 110 | Content-Location header field that has the same value as the POST's 111 | effective request URI (Section 3.1.4.2). 112 | */ 113 | if obj.ReqMethod == http.MethodPost && !hasFreshness(obj.RespDirectives, obj.RespHeaders, obj.RespExpiresHeader, obj.CacheIsPrivate) { 114 | rv.OutReasons = append(rv.OutReasons, ReasonRequestMethodPOST) 115 | } 116 | 117 | // Storing Responses to Authenticated Requests: http://tools.ietf.org/html/rfc7234#section-3.2 118 | if obj.ReqHeaders.Get("Authorization") != "" { 119 | if obj.RespDirectives.MustRevalidate || 120 | obj.RespDirectives.Public || 121 | obj.RespDirectives.SMaxAge != -1 { 122 | // Expires of some kind present, this is potentially OK. 123 | } else { 124 | rv.OutReasons = append(rv.OutReasons, ReasonRequestAuthorizationHeader) 125 | } 126 | } 127 | 128 | if obj.RespDirectives.PrivatePresent && !obj.CacheIsPrivate { 129 | rv.OutReasons = append(rv.OutReasons, ReasonResponsePrivate) 130 | } 131 | 132 | if obj.RespDirectives.NoStore { 133 | rv.OutReasons = append(rv.OutReasons, ReasonResponseNoStore) 134 | } 135 | 136 | /* 137 | the response either: 138 | 139 | * contains an Expires header field (see Section 5.3), or 140 | 141 | * contains a max-age response directive (see Section 5.2.2.8), or 142 | 143 | * contains a s-maxage response directive (see Section 5.2.2.9) 144 | and the cache is shared, or 145 | 146 | * contains a Cache Control Extension (see Section 5.2.3) that 147 | allows it to be cached, or 148 | 149 | * has a status code that is defined as cacheable by default (see 150 | Section 4.2.2), or 151 | 152 | * contains a public response directive (see Section 5.2.2.5). 153 | */ 154 | 155 | if obj.RespHeaders.Get("Expires") != "" || 156 | obj.RespDirectives.MaxAge != -1 || 157 | (obj.RespDirectives.SMaxAge != -1 && !obj.CacheIsPrivate) || 158 | cachableStatusCode(obj.RespStatusCode) || 159 | obj.RespDirectives.Public { 160 | /* cachable by default, at least one of the above conditions was true */ 161 | return 162 | } 163 | 164 | rv.OutReasons = append(rv.OutReasons, ReasonResponseUncachableByDefault) 165 | } 166 | 167 | // LOW LEVEL API: Check if a object is cachable. 168 | func CachableObject(obj *Object, rv *ObjectResults) { 169 | rv.OutReasons = nil 170 | rv.OutWarnings = nil 171 | rv.OutErr = nil 172 | 173 | CachableRequestObject(obj, rv) 174 | CachableResponseObject(obj, rv) 175 | } 176 | 177 | var twentyFourHours = time.Duration(24 * time.Hour) 178 | 179 | const debug = false 180 | 181 | // LOW LEVEL API: Update an objects expiration time. 182 | func ExpirationObject(obj *Object, rv *ObjectResults) { 183 | /** 184 | * Okay, lets calculate Freshness/Expiration now. woo: 185 | * http://tools.ietf.org/html/rfc7234#section-4.2 186 | */ 187 | 188 | /* 189 | o If the cache is shared and the s-maxage response directive 190 | (Section 5.2.2.9) is present, use its value, or 191 | 192 | o If the max-age response directive (Section 5.2.2.8) is present, 193 | use its value, or 194 | 195 | o If the Expires response header field (Section 5.3) is present, use 196 | its value minus the value of the Date response header field, or 197 | 198 | o Otherwise, no explicit expiration time is present in the response. 199 | A heuristic freshness lifetime might be applicable; see 200 | Section 4.2.2. 201 | */ 202 | 203 | var expiresTime time.Time 204 | 205 | if obj.RespDirectives.SMaxAge != -1 && !obj.CacheIsPrivate { 206 | expiresTime = obj.NowUTC.Add(time.Second * time.Duration(obj.RespDirectives.SMaxAge)) 207 | } else if obj.RespDirectives.MaxAge != -1 { 208 | expiresTime = obj.NowUTC.UTC().Add(time.Second * time.Duration(obj.RespDirectives.MaxAge)) 209 | } else if !obj.RespExpiresHeader.IsZero() { 210 | serverDate := obj.RespDateHeader 211 | if serverDate.IsZero() { 212 | // common enough case when a Date: header has not yet been added to an 213 | // active response. 214 | serverDate = obj.NowUTC 215 | } 216 | expiresTime = obj.NowUTC.Add(obj.RespExpiresHeader.Sub(serverDate)) 217 | } else if !obj.RespLastModifiedHeader.IsZero() { 218 | // heuristic freshness lifetime 219 | rv.OutWarnings = append(rv.OutWarnings, WarningHeuristicExpiration) 220 | 221 | // http://httpd.apache.org/docs/2.4/mod/mod_cache.html#cachelastmodifiedfactor 222 | // CacheMaxExpire defaults to 24 hours 223 | // CacheLastModifiedFactor: is 0.1 224 | // 225 | // expiry-period = MIN(time-since-last-modified-date * factor, 24 hours) 226 | // 227 | // obj.NowUTC 228 | 229 | since := obj.RespLastModifiedHeader.Sub(obj.NowUTC) 230 | since = time.Duration(float64(since) * -0.1) 231 | 232 | if since > twentyFourHours { 233 | expiresTime = obj.NowUTC.Add(twentyFourHours) 234 | } else { 235 | expiresTime = obj.NowUTC.Add(since) 236 | } 237 | 238 | if debug { 239 | println("Now UTC: ", obj.NowUTC.String()) 240 | println("Last-Modified: ", obj.RespLastModifiedHeader.String()) 241 | println("Since: ", since.String()) 242 | println("TwentyFourHours: ", twentyFourHours.String()) 243 | println("Expiration: ", expiresTime.String()) 244 | } 245 | } else { 246 | // TODO(pquerna): what should the default behavior be for expiration time? 247 | } 248 | 249 | rv.OutExpirationTime = expiresTime 250 | } 251 | 252 | // Evaluate cachability based on an HTTP request, and parts of the response. 253 | func UsingRequestResponse(req *http.Request, 254 | statusCode int, 255 | respHeaders http.Header, 256 | privateCache bool) ([]Reason, time.Time, error) { 257 | reasons, time, _, _, err := UsingRequestResponseWithObject(req, statusCode, respHeaders, privateCache) 258 | return reasons, time, err 259 | } 260 | 261 | // Evaluate cachability based on an HTTP request, and parts of the response. 262 | // Returns the parsed Object as well. 263 | func UsingRequestResponseWithObject(req *http.Request, 264 | statusCode int, 265 | respHeaders http.Header, 266 | privateCache bool) ([]Reason, time.Time, []Warning, *Object, error) { 267 | var reqHeaders http.Header 268 | var reqMethod string 269 | 270 | var reqDir *RequestCacheDirectives = nil 271 | respDir, err := ParseResponseCacheControl(respHeaders.Get("Cache-Control")) 272 | if err != nil { 273 | return nil, time.Time{}, nil, nil, err 274 | } 275 | 276 | if req != nil { 277 | reqDir, err = ParseRequestCacheControl(req.Header.Get("Cache-Control")) 278 | if err != nil { 279 | return nil, time.Time{}, nil, nil, err 280 | } 281 | reqHeaders = req.Header 282 | reqMethod = req.Method 283 | } 284 | 285 | var expiresHeader time.Time 286 | var dateHeader time.Time 287 | var lastModifiedHeader time.Time 288 | 289 | if respHeaders.Get("Expires") != "" { 290 | expiresHeader, err = http.ParseTime(respHeaders.Get("Expires")) 291 | if err != nil { 292 | // sometimes servers will return `Expires: 0` or `Expires: -1` to 293 | // indicate expired content 294 | expiresHeader = time.Time{} 295 | } 296 | expiresHeader = expiresHeader.UTC() 297 | } 298 | 299 | if respHeaders.Get("Date") != "" { 300 | dateHeader, err = http.ParseTime(respHeaders.Get("Date")) 301 | if err != nil { 302 | return nil, time.Time{}, nil, nil, err 303 | } 304 | dateHeader = dateHeader.UTC() 305 | } 306 | 307 | if respHeaders.Get("Last-Modified") != "" { 308 | lastModifiedHeader, err = http.ParseTime(respHeaders.Get("Last-Modified")) 309 | if err != nil { 310 | return nil, time.Time{}, nil, nil, err 311 | } 312 | lastModifiedHeader = lastModifiedHeader.UTC() 313 | } 314 | 315 | obj := Object{ 316 | CacheIsPrivate: privateCache, 317 | 318 | RespDirectives: respDir, 319 | RespHeaders: respHeaders, 320 | RespStatusCode: statusCode, 321 | RespExpiresHeader: expiresHeader, 322 | RespDateHeader: dateHeader, 323 | RespLastModifiedHeader: lastModifiedHeader, 324 | 325 | ReqDirectives: reqDir, 326 | ReqHeaders: reqHeaders, 327 | ReqMethod: reqMethod, 328 | 329 | NowUTC: time.Now().UTC(), 330 | } 331 | rv := ObjectResults{} 332 | 333 | CachableObject(&obj, &rv) 334 | if rv.OutErr != nil { 335 | return nil, time.Time{}, nil, nil, rv.OutErr 336 | } 337 | 338 | ExpirationObject(&obj, &rv) 339 | if rv.OutErr != nil { 340 | return nil, time.Time{}, nil, nil, rv.OutErr 341 | } 342 | 343 | return rv.OutReasons, rv.OutExpirationTime, rv.OutWarnings, &obj, nil 344 | } 345 | 346 | // calculate if a freshness directive is present: http://tools.ietf.org/html/rfc7234#section-4.2.1 347 | func hasFreshness(respDir *ResponseCacheDirectives, respHeaders http.Header, respExpires time.Time, privateCache bool) bool { 348 | if !privateCache && respDir.SMaxAge != -1 { 349 | return true 350 | } 351 | 352 | if respDir.MaxAge != -1 { 353 | return true 354 | } 355 | 356 | if !respExpires.IsZero() || respHeaders.Get("Expires") != "" { 357 | return true 358 | } 359 | 360 | return false 361 | } 362 | 363 | func cachableStatusCode(statusCode int) bool { 364 | /* 365 | Responses with status codes that are defined as cacheable by default 366 | (e.g., 200, 203, 204, 206, 300, 301, 404, 405, 410, 414, and 501 in 367 | this specification) can be reused by a cache with heuristic 368 | expiration unless otherwise indicated by the method definition or 369 | explicit cache controls [RFC7234]; all other status codes are not 370 | cacheable by default. 371 | */ 372 | switch statusCode { 373 | case 200: 374 | return true 375 | case 203: 376 | return true 377 | case 204: 378 | return true 379 | case 206: 380 | return true 381 | case 300: 382 | return true 383 | case 301: 384 | return true 385 | case 404: 386 | return true 387 | case 405: 388 | return true 389 | case 410: 390 | return true 391 | case 414: 392 | return true 393 | case 501: 394 | return true 395 | default: 396 | return false 397 | } 398 | } 399 | -------------------------------------------------------------------------------- /cacheobject/object_http_test.go: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2015 Paul Querna 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | 18 | package cacheobject 19 | 20 | import ( 21 | "github.com/stretchr/testify/require" 22 | 23 | "fmt" 24 | "io/ioutil" 25 | "net/http" 26 | "net/http/httptest" 27 | "testing" 28 | "time" 29 | ) 30 | 31 | func roundTrip(t *testing.T, fnc func(w http.ResponseWriter, r *http.Request)) (*http.Request, *http.Response) { 32 | ts := httptest.NewServer(http.HandlerFunc(fnc)) 33 | defer ts.Close() 34 | 35 | req, err := http.NewRequest("GET", ts.URL, nil) 36 | require.NoError(t, err) 37 | 38 | res, err := http.DefaultClient.Do(req) 39 | require.NoError(t, err) 40 | 41 | _, err = ioutil.ReadAll(res.Body) 42 | res.Body.Close() 43 | require.NoError(t, err) 44 | return req, res 45 | } 46 | 47 | func TestCachableResponsePublic(t *testing.T) { 48 | req, res := roundTrip(t, func(w http.ResponseWriter, r *http.Request) { 49 | w.Header().Set("Content-Type", "application/json") 50 | w.Header().Set("Cache-Control", "public") 51 | w.Header().Set("Last-Modified", 52 | time.Now().UTC().Add(time.Duration(time.Hour*-5)).Format(http.TimeFormat)) 53 | fmt.Fprintln(w, `{}`) 54 | }) 55 | 56 | reasons, expires, err := UsingRequestResponse(req, res.StatusCode, res.Header, false) 57 | 58 | require.NoError(t, err) 59 | require.Len(t, reasons, 0) 60 | require.WithinDuration(t, 61 | time.Now().UTC().Add(time.Duration(float64(time.Hour)*0.5)), 62 | expires, 63 | 10*time.Second) 64 | } 65 | 66 | func TestCachableResponseNoHeaders(t *testing.T) { 67 | req, res := roundTrip(t, func(w http.ResponseWriter, r *http.Request) { 68 | w.Header().Set("Content-Type", "application/json") 69 | fmt.Fprintln(w, `{}`) 70 | }) 71 | 72 | reasons, expires, err := UsingRequestResponse(req, res.StatusCode, res.Header, false) 73 | 74 | require.NoError(t, err) 75 | require.Len(t, reasons, 0) 76 | require.True(t, expires.IsZero()) 77 | } 78 | 79 | func TestCachableResponseBadExpires(t *testing.T) { 80 | req, res := roundTrip(t, func(w http.ResponseWriter, r *http.Request) { 81 | w.Header().Set("Content-Type", "application/json") 82 | w.Header().Set("Expires", "-1") 83 | fmt.Fprintln(w, `{}`) 84 | }) 85 | 86 | reasons, expires, err := UsingRequestResponse(req, res.StatusCode, res.Header, false) 87 | 88 | require.NoError(t, err) 89 | require.Len(t, reasons, 0) 90 | require.True(t, expires.IsZero()) 91 | } 92 | -------------------------------------------------------------------------------- /cacheobject/object_test.go: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2015 Paul Querna 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | 18 | package cacheobject 19 | 20 | import ( 21 | "github.com/stretchr/testify/require" 22 | 23 | "net/http" 24 | "testing" 25 | "time" 26 | ) 27 | 28 | func TestCachableStatusCode(t *testing.T) { 29 | ok := []int{200, 203, 204, 206, 300, 301, 404, 405, 410, 414, 501} 30 | for _, v := range ok { 31 | require.True(t, cachableStatusCode(v), "status code should be cacheable: %d", v) 32 | } 33 | 34 | notok := []int{201, 429, 500, 504} 35 | for _, v := range notok { 36 | require.False(t, cachableStatusCode(v), "status code should not be cachable: %d", v) 37 | } 38 | } 39 | 40 | func fill(t *testing.T, now time.Time) Object { 41 | RespDirectives, err := ParseResponseCacheControl("") 42 | require.NoError(t, err) 43 | ReqDirectives, err := ParseRequestCacheControl("") 44 | require.NoError(t, err) 45 | 46 | obj := Object{ 47 | RespDirectives: RespDirectives, 48 | RespHeaders: http.Header{}, 49 | RespStatusCode: 200, 50 | RespDateHeader: now, 51 | 52 | ReqDirectives: ReqDirectives, 53 | ReqHeaders: http.Header{}, 54 | ReqMethod: "GET", 55 | 56 | NowUTC: now, 57 | } 58 | 59 | return obj 60 | } 61 | 62 | func TestGETPrivate(t *testing.T) { 63 | now := time.Now().UTC() 64 | 65 | obj := fill(t, now) 66 | RespDirectives, err := ParseResponseCacheControl("private") 67 | require.NoError(t, err) 68 | 69 | obj.RespDirectives = RespDirectives 70 | 71 | rv := ObjectResults{} 72 | CachableObject(&obj, &rv) 73 | require.NoError(t, rv.OutErr) 74 | require.Len(t, rv.OutReasons, 1) 75 | require.Contains(t, rv.OutReasons, ReasonResponsePrivate) 76 | } 77 | 78 | func TestGETPrivateWithPrivateCache(t *testing.T) { 79 | now := time.Now().UTC() 80 | 81 | obj := fill(t, now) 82 | RespDirectives, err := ParseResponseCacheControl("private") 83 | require.NoError(t, err) 84 | 85 | obj.CacheIsPrivate = true 86 | obj.RespDirectives = RespDirectives 87 | 88 | rv := ObjectResults{} 89 | CachableObject(&obj, &rv) 90 | require.NoError(t, rv.OutErr) 91 | require.Len(t, rv.OutReasons, 0) 92 | } 93 | 94 | func TestUncachableMethods(t *testing.T) { 95 | type methodPair struct { 96 | m string 97 | r Reason 98 | } 99 | 100 | tc := []methodPair{ 101 | {"PUT", ReasonRequestMethodPUT}, 102 | {"DELETE", ReasonRequestMethodDELETE}, 103 | {"CONNECT", ReasonRequestMethodCONNECT}, 104 | {"OPTIONS", ReasonRequestMethodOPTIONS}, 105 | {"CONNECT", ReasonRequestMethodCONNECT}, 106 | {"TRACE", ReasonRequestMethodTRACE}, 107 | {"MADEUP", ReasonRequestMethodUnknown}, 108 | } 109 | 110 | for _, mp := range tc { 111 | now := time.Now().UTC() 112 | 113 | obj := fill(t, now) 114 | obj.ReqMethod = mp.m 115 | 116 | rv := ObjectResults{} 117 | CachableObject(&obj, &rv) 118 | require.NoError(t, rv.OutErr) 119 | require.Len(t, rv.OutReasons, 1) 120 | require.Contains(t, rv.OutReasons, mp.r) 121 | } 122 | } 123 | 124 | func TestHEAD(t *testing.T) { 125 | now := time.Now().UTC() 126 | 127 | obj := fill(t, now) 128 | obj.ReqMethod = "HEAD" 129 | obj.RespLastModifiedHeader = now.Add(time.Hour * -1) 130 | 131 | rv := ObjectResults{} 132 | CachableObject(&obj, &rv) 133 | require.NoError(t, rv.OutErr) 134 | require.Len(t, rv.OutReasons, 0) 135 | 136 | ExpirationObject(&obj, &rv) 137 | require.NoError(t, rv.OutErr) 138 | require.Len(t, rv.OutReasons, 0) 139 | require.False(t, rv.OutExpirationTime.IsZero()) 140 | } 141 | 142 | func TestHEADLongLastModified(t *testing.T) { 143 | now := time.Now().UTC() 144 | 145 | obj := fill(t, now) 146 | obj.ReqMethod = "HEAD" 147 | obj.RespLastModifiedHeader = now.Add(time.Hour * -70000) 148 | 149 | rv := ObjectResults{} 150 | CachableObject(&obj, &rv) 151 | require.NoError(t, rv.OutErr) 152 | require.Len(t, rv.OutReasons, 0) 153 | 154 | ExpirationObject(&obj, &rv) 155 | require.NoError(t, rv.OutErr) 156 | require.Len(t, rv.OutReasons, 0) 157 | require.False(t, rv.OutExpirationTime.IsZero()) 158 | require.WithinDuration(t, now.Add(twentyFourHours), rv.OutExpirationTime, time.Second*60) 159 | } 160 | 161 | func TestNonCachablePOST(t *testing.T) { 162 | now := time.Now().UTC() 163 | 164 | obj := fill(t, now) 165 | obj.ReqMethod = "POST" 166 | 167 | rv := ObjectResults{} 168 | CachableObject(&obj, &rv) 169 | require.NoError(t, rv.OutErr) 170 | require.Len(t, rv.OutReasons, 1) 171 | require.Contains(t, rv.OutReasons, ReasonRequestMethodPOST) 172 | } 173 | 174 | func TestCachablePOSTExpiresHeader(t *testing.T) { 175 | now := time.Now().UTC() 176 | 177 | obj := fill(t, now) 178 | obj.ReqMethod = "POST" 179 | obj.RespExpiresHeader = now.Add(time.Hour * 1) 180 | 181 | rv := ObjectResults{} 182 | CachableObject(&obj, &rv) 183 | require.NoError(t, rv.OutErr) 184 | require.Len(t, rv.OutReasons, 0) 185 | } 186 | 187 | func TestCachablePOSTSMax(t *testing.T) { 188 | now := time.Now().UTC() 189 | 190 | obj := fill(t, now) 191 | obj.ReqMethod = "POST" 192 | obj.RespDirectives.SMaxAge = DeltaSeconds(900) 193 | 194 | rv := ObjectResults{} 195 | CachableObject(&obj, &rv) 196 | require.NoError(t, rv.OutErr) 197 | require.Len(t, rv.OutReasons, 0) 198 | } 199 | 200 | func TestNonCachablePOSTSMax(t *testing.T) { 201 | now := time.Now().UTC() 202 | 203 | obj := fill(t, now) 204 | obj.ReqMethod = "POST" 205 | obj.CacheIsPrivate = true 206 | obj.RespDirectives.SMaxAge = DeltaSeconds(900) 207 | 208 | rv := ObjectResults{} 209 | CachableObject(&obj, &rv) 210 | require.NoError(t, rv.OutErr) 211 | require.Len(t, rv.OutReasons, 1) 212 | require.Contains(t, rv.OutReasons, ReasonRequestMethodPOST) 213 | } 214 | 215 | func TestCachablePOSTMax(t *testing.T) { 216 | now := time.Now().UTC() 217 | 218 | obj := fill(t, now) 219 | obj.ReqMethod = "POST" 220 | obj.RespDirectives.MaxAge = DeltaSeconds(9000) 221 | 222 | rv := ObjectResults{} 223 | CachableObject(&obj, &rv) 224 | require.NoError(t, rv.OutErr) 225 | require.Len(t, rv.OutReasons, 0) 226 | } 227 | 228 | func TestPUTs(t *testing.T) { 229 | now := time.Now().UTC() 230 | 231 | obj := fill(t, now) 232 | obj.ReqMethod = "PUT" 233 | 234 | rv := ObjectResults{} 235 | CachableObject(&obj, &rv) 236 | require.NoError(t, rv.OutErr) 237 | require.Len(t, rv.OutReasons, 1) 238 | require.Contains(t, rv.OutReasons, ReasonRequestMethodPUT) 239 | } 240 | 241 | func TestPUTWithExpires(t *testing.T) { 242 | now := time.Now().UTC() 243 | 244 | obj := fill(t, now) 245 | obj.ReqMethod = "PUT" 246 | obj.RespExpiresHeader = now.Add(time.Hour * 1) 247 | 248 | rv := ObjectResults{} 249 | CachableObject(&obj, &rv) 250 | require.NoError(t, rv.OutErr) 251 | require.Len(t, rv.OutReasons, 1) 252 | require.Contains(t, rv.OutReasons, ReasonRequestMethodPUT) 253 | } 254 | 255 | func TestAuthorization(t *testing.T) { 256 | now := time.Now().UTC() 257 | 258 | obj := fill(t, now) 259 | obj.ReqHeaders.Set("Authorization", "bearer random") 260 | 261 | rv := ObjectResults{} 262 | CachableObject(&obj, &rv) 263 | require.NoError(t, rv.OutErr) 264 | require.Len(t, rv.OutReasons, 1) 265 | require.Contains(t, rv.OutReasons, ReasonRequestAuthorizationHeader) 266 | } 267 | 268 | func TestCachableAuthorization(t *testing.T) { 269 | now := time.Now().UTC() 270 | 271 | obj := fill(t, now) 272 | obj.ReqHeaders.Set("Authorization", "bearer random") 273 | obj.RespDirectives.Public = true 274 | obj.RespDirectives.MaxAge = DeltaSeconds(300) 275 | 276 | rv := ObjectResults{} 277 | CachableObject(&obj, &rv) 278 | require.NoError(t, rv.OutErr) 279 | require.Len(t, rv.OutReasons, 0) 280 | } 281 | 282 | func TestRespNoStore(t *testing.T) { 283 | now := time.Now().UTC() 284 | 285 | obj := fill(t, now) 286 | obj.RespDirectives.NoStore = true 287 | 288 | rv := ObjectResults{} 289 | CachableObject(&obj, &rv) 290 | require.Len(t, rv.OutReasons, 1) 291 | require.Contains(t, rv.OutReasons, ReasonResponseNoStore) 292 | } 293 | 294 | func TestReqNoStore(t *testing.T) { 295 | now := time.Now().UTC() 296 | 297 | obj := fill(t, now) 298 | obj.ReqDirectives.NoStore = true 299 | 300 | rv := ObjectResults{} 301 | CachableObject(&obj, &rv) 302 | require.Len(t, rv.OutReasons, 1) 303 | require.Contains(t, rv.OutReasons, ReasonRequestNoStore) 304 | } 305 | 306 | func TestResp500(t *testing.T) { 307 | now := time.Now().UTC() 308 | 309 | obj := fill(t, now) 310 | obj.RespStatusCode = 500 311 | 312 | rv := ObjectResults{} 313 | CachableObject(&obj, &rv) 314 | require.Len(t, rv.OutReasons, 1) 315 | require.Contains(t, rv.OutReasons, ReasonResponseUncachableByDefault) 316 | } 317 | 318 | func TestExpirationSMaxShared(t *testing.T) { 319 | now := time.Now().UTC() 320 | 321 | obj := fill(t, now) 322 | obj.RespDirectives.SMaxAge = DeltaSeconds(60) 323 | 324 | rv := ObjectResults{} 325 | ExpirationObject(&obj, &rv) 326 | require.Len(t, rv.OutWarnings, 0) 327 | require.WithinDuration(t, now.Add(time.Second*60), rv.OutExpirationTime, time.Second*1) 328 | } 329 | 330 | func TestExpirationSMaxPrivate(t *testing.T) { 331 | now := time.Now().UTC() 332 | 333 | obj := fill(t, now) 334 | obj.CacheIsPrivate = true 335 | obj.RespDirectives.SMaxAge = DeltaSeconds(60) 336 | 337 | rv := ObjectResults{} 338 | ExpirationObject(&obj, &rv) 339 | require.Len(t, rv.OutWarnings, 0) 340 | require.True(t, rv.OutExpirationTime.IsZero()) 341 | } 342 | 343 | func TestExpirationMax(t *testing.T) { 344 | now := time.Now().UTC() 345 | 346 | obj := fill(t, now) 347 | obj.RespDirectives.MaxAge = DeltaSeconds(60) 348 | 349 | rv := ObjectResults{} 350 | ExpirationObject(&obj, &rv) 351 | require.Len(t, rv.OutWarnings, 0) 352 | require.WithinDuration(t, now.Add(time.Second*60), rv.OutExpirationTime, time.Second*1) 353 | } 354 | 355 | func TestExpirationMaxAndSMax(t *testing.T) { 356 | now := time.Now().UTC() 357 | 358 | obj := fill(t, now) 359 | // cache should select the SMax age since this is a shared cache. 360 | obj.RespDirectives.MaxAge = DeltaSeconds(60) 361 | obj.RespDirectives.SMaxAge = DeltaSeconds(900) 362 | 363 | rv := ObjectResults{} 364 | ExpirationObject(&obj, &rv) 365 | require.Len(t, rv.OutWarnings, 0) 366 | require.WithinDuration(t, now.Add(time.Second*900), rv.OutExpirationTime, time.Second*1) 367 | } 368 | 369 | func TestExpirationExpires(t *testing.T) { 370 | now := time.Now().UTC() 371 | 372 | obj := fill(t, now) 373 | // cache should select the SMax age since this is a shared cache. 374 | obj.RespExpiresHeader = now.Add(time.Second * 1500) 375 | 376 | rv := ObjectResults{} 377 | ExpirationObject(&obj, &rv) 378 | require.Len(t, rv.OutWarnings, 0) 379 | require.WithinDuration(t, now.Add(time.Second*1500), rv.OutExpirationTime, time.Second*1) 380 | } 381 | 382 | func TestExpirationExpiresNoServerDate(t *testing.T) { 383 | now := time.Now().UTC() 384 | 385 | obj := fill(t, now) 386 | // cache should select the SMax age since this is a shared cache. 387 | obj.RespDateHeader = time.Time{} 388 | obj.RespExpiresHeader = now.Add(time.Second * 1500) 389 | 390 | rv := ObjectResults{} 391 | ExpirationObject(&obj, &rv) 392 | require.Len(t, rv.OutWarnings, 0) 393 | require.WithinDuration(t, now.Add(time.Second*1500), rv.OutExpirationTime, time.Second*1) 394 | } 395 | 396 | func TestCachableRequestObject(t *testing.T) { 397 | ReqDirectives, err := ParseRequestCacheControl("") 398 | require.NoError(t, err) 399 | 400 | obj := Object{ 401 | ReqDirectives: ReqDirectives, 402 | ReqHeaders: http.Header{}, 403 | ReqMethod: "GET", 404 | 405 | NowUTC: time.Now().UTC(), 406 | } 407 | 408 | rv := ObjectResults{} 409 | CachableRequestObject(&obj, &rv) 410 | require.Len(t, rv.OutReasons, 0) 411 | 412 | obj.ReqMethod = "PUT" 413 | rv.OutReasons = nil 414 | CachableRequestObject(&obj, &rv) 415 | require.Len(t, rv.OutReasons, 1) 416 | } 417 | 418 | func TestCachableResponseObject(t *testing.T) { 419 | obj := fill(t, time.Now().UTC()) 420 | obj.ReqMethod = "DELETE" 421 | obj.RespDirectives.NoStore = true 422 | 423 | rv := ObjectResults{} 424 | CachableRequestObject(&obj, &rv) 425 | require.Len(t, rv.OutReasons, 1) 426 | CachableResponseObject(&obj, &rv) 427 | require.Len(t, rv.OutReasons, 2) 428 | } 429 | -------------------------------------------------------------------------------- /cacheobject/reasons.go: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2015 Paul Querna 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | 18 | package cacheobject 19 | 20 | // Repersents a potential Reason to not cache an object. 21 | // 22 | // Applications may wish to ignore specific reasons, which will make them non-RFC 23 | // compliant, but this type gives them specific cases they can choose to ignore, 24 | // making them compliant in as many cases as they can. 25 | type Reason int 26 | 27 | const ( 28 | 29 | // The request method was POST and an Expiration header was not supplied. 30 | ReasonRequestMethodPOST Reason = iota 31 | 32 | // The request method was PUT and PUTs are not cachable. 33 | ReasonRequestMethodPUT 34 | 35 | // The request method was DELETE and DELETEs are not cachable. 36 | ReasonRequestMethodDELETE 37 | 38 | // The request method was CONNECT and CONNECTs are not cachable. 39 | ReasonRequestMethodCONNECT 40 | 41 | // The request method was OPTIONS and OPTIONS are not cachable. 42 | ReasonRequestMethodOPTIONS 43 | 44 | // The request method was TRACE and TRACEs are not cachable. 45 | ReasonRequestMethodTRACE 46 | 47 | // The request method was not recognized by cachecontrol, and should not be cached. 48 | ReasonRequestMethodUnknown 49 | 50 | // The request included an Cache-Control: no-store header 51 | ReasonRequestNoStore 52 | 53 | // The request included an Authorization header without an explicit Public or Expiration time: http://tools.ietf.org/html/rfc7234#section-3.2 54 | ReasonRequestAuthorizationHeader 55 | 56 | // The response included an Cache-Control: no-store header 57 | ReasonResponseNoStore 58 | 59 | // The response included an Cache-Control: private header and this is not a Private cache 60 | ReasonResponsePrivate 61 | 62 | // The response failed to meet at least one of the conditions specified in RFC 7234 section 3: http://tools.ietf.org/html/rfc7234#section-3 63 | ReasonResponseUncachableByDefault 64 | ) 65 | 66 | func (r Reason) String() string { 67 | switch r { 68 | case ReasonRequestMethodPOST: 69 | return "ReasonRequestMethodPOST" 70 | case ReasonRequestMethodPUT: 71 | return "ReasonRequestMethodPUT" 72 | case ReasonRequestMethodDELETE: 73 | return "ReasonRequestMethodDELETE" 74 | case ReasonRequestMethodCONNECT: 75 | return "ReasonRequestMethodCONNECT" 76 | case ReasonRequestMethodOPTIONS: 77 | return "ReasonRequestMethodOPTIONS" 78 | case ReasonRequestMethodTRACE: 79 | return "ReasonRequestMethodTRACE" 80 | case ReasonRequestMethodUnknown: 81 | return "ReasonRequestMethodUnkown" 82 | case ReasonRequestNoStore: 83 | return "ReasonRequestNoStore" 84 | case ReasonRequestAuthorizationHeader: 85 | return "ReasonRequestAuthorizationHeader" 86 | case ReasonResponseNoStore: 87 | return "ReasonResponseNoStore" 88 | case ReasonResponsePrivate: 89 | return "ReasonResponsePrivate" 90 | case ReasonResponseUncachableByDefault: 91 | return "ReasonResponseUncachableByDefault" 92 | } 93 | 94 | panic(r) 95 | } 96 | -------------------------------------------------------------------------------- /cacheobject/warning.go: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2015 Paul Querna 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | 18 | package cacheobject 19 | 20 | import ( 21 | "fmt" 22 | "net/http" 23 | "time" 24 | ) 25 | 26 | // Repersents an HTTP Warning: http://tools.ietf.org/html/rfc7234#section-5.5 27 | type Warning int 28 | 29 | const ( 30 | // Response is Stale 31 | // A cache SHOULD generate this whenever the sent response is stale. 32 | WarningResponseIsStale Warning = 110 33 | 34 | // Revalidation Failed 35 | // A cache SHOULD generate this when sending a stale 36 | // response because an attempt to validate the response failed, due to an 37 | // inability to reach the server. 38 | WarningRevalidationFailed Warning = 111 39 | 40 | // Disconnected Operation 41 | // A cache SHOULD generate this if it is intentionally disconnected from 42 | // the rest of the network for a period of time. 43 | WarningDisconnectedOperation Warning = 112 44 | 45 | // Heuristic Expiration 46 | // 47 | // A cache SHOULD generate this if it heuristically chose a freshness 48 | // lifetime greater than 24 hours and the response's age is greater than 49 | // 24 hours. 50 | WarningHeuristicExpiration Warning = 113 51 | 52 | // Miscellaneous Warning 53 | // 54 | // The warning text can include arbitrary information to be presented to 55 | // a human user or logged. A system receiving this warning MUST NOT 56 | // take any automated action, besides presenting the warning to the 57 | // user. 58 | WarningMiscellaneousWarning Warning = 199 59 | 60 | // Transformation Applied 61 | // 62 | // This Warning code MUST be added by a proxy if it applies any 63 | // transformation to the representation, such as changing the 64 | // content-coding, media-type, or modifying the representation data, 65 | // unless this Warning code already appears in the response. 66 | WarningTransformationApplied Warning = 214 67 | 68 | // Miscellaneous Persistent Warning 69 | // 70 | // The warning text can include arbitrary information to be presented to 71 | // a human user or logged. A system receiving this warning MUST NOT 72 | // take any automated action. 73 | WarningMiscellaneousPersistentWarning Warning = 299 74 | ) 75 | 76 | func (w Warning) HeaderString(agent string, date time.Time) string { 77 | if agent == "" { 78 | agent = "-" 79 | } else { 80 | // TODO(pquerna): this doesn't escape agent if it contains bad things. 81 | agent = `"` + agent + `"` 82 | } 83 | return fmt.Sprintf(`%d %s "%s" %s`, w, agent, w.String(), date.Format(http.TimeFormat)) 84 | } 85 | 86 | func (w Warning) String() string { 87 | switch w { 88 | case WarningResponseIsStale: 89 | return "Response is Stale" 90 | case WarningRevalidationFailed: 91 | return "Revalidation Failed" 92 | case WarningDisconnectedOperation: 93 | return "Disconnected Operation" 94 | case WarningHeuristicExpiration: 95 | return "Heuristic Expiration" 96 | case WarningMiscellaneousWarning: 97 | // TODO(pquerna): ideally had a better way to override this one code. 98 | return "Miscellaneous Warning" 99 | case WarningTransformationApplied: 100 | return "Transformation Applied" 101 | case WarningMiscellaneousPersistentWarning: 102 | // TODO(pquerna): same as WarningMiscellaneousWarning 103 | return "Miscellaneous Persistent Warning" 104 | } 105 | 106 | panic(w) 107 | } 108 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2015 Paul Querna 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | 18 | // Package cachecontrol implements the logic for HTTP Caching 19 | // 20 | // Deciding if an HTTP Response can be cached is often harder 21 | // and more bug prone than an actual cache storage backend. 22 | // cachecontrol provides a simple interface to determine if 23 | // request and response pairs are cachable as defined under 24 | // RFC 7234 http://tools.ietf.org/html/rfc7234 25 | package cachecontrol 26 | -------------------------------------------------------------------------------- /examples/example-com.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/pquerna/cachecontrol" 5 | 6 | "fmt" 7 | "io/ioutil" 8 | "net/http" 9 | ) 10 | 11 | func main() { 12 | req, _ := http.NewRequest("GET", "http://www.example.com/", nil) 13 | 14 | res, _ := http.DefaultClient.Do(req) 15 | _, _ = ioutil.ReadAll(res.Body) 16 | 17 | reasons, expires, _ := cachecontrol.CachableResponse(req, res, cachecontrol.Options{}) 18 | 19 | fmt.Println("Reasons to not cache: ", reasons) 20 | fmt.Println("Expiration: ", expires.String()) 21 | } 22 | -------------------------------------------------------------------------------- /examples/lowlevel/ll-example-com.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/pquerna/cachecontrol/cacheobject" 5 | 6 | "fmt" 7 | "io/ioutil" 8 | "net/http" 9 | "time" 10 | ) 11 | 12 | func main() { 13 | req, _ := http.NewRequest("GET", "http://www.example.com/", nil) 14 | 15 | res, _ := http.DefaultClient.Do(req) 16 | _, _ = ioutil.ReadAll(res.Body) 17 | 18 | reqDir, _ := cacheobject.ParseRequestCacheControl(req.Header.Get("Cache-Control")) 19 | 20 | resDir, _ := cacheobject.ParseResponseCacheControl(res.Header.Get("Cache-Control")) 21 | expiresHeader, _ := http.ParseTime(res.Header.Get("Expires")) 22 | dateHeader, _ := http.ParseTime(res.Header.Get("Date")) 23 | lastModifiedHeader, _ := http.ParseTime(res.Header.Get("Last-Modified")) 24 | 25 | obj := cacheobject.Object{ 26 | RespDirectives: resDir, 27 | RespHeaders: res.Header, 28 | RespStatusCode: res.StatusCode, 29 | RespExpiresHeader: expiresHeader, 30 | RespDateHeader: dateHeader, 31 | RespLastModifiedHeader: lastModifiedHeader, 32 | 33 | ReqDirectives: reqDir, 34 | ReqHeaders: req.Header, 35 | ReqMethod: req.Method, 36 | 37 | NowUTC: time.Now().UTC(), 38 | } 39 | rv := cacheobject.ObjectResults{} 40 | 41 | cacheobject.CachableObject(&obj, &rv) 42 | cacheobject.ExpirationObject(&obj, &rv) 43 | 44 | fmt.Println("Errors: ", rv.OutErr) 45 | fmt.Println("Reasons to not cache: ", rv.OutReasons) 46 | fmt.Println("Warning headers to add: ", rv.OutWarnings) 47 | fmt.Println("Expiration: ", rv.OutExpirationTime.String()) 48 | } 49 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/pquerna/cachecontrol 2 | 3 | go 1.16 4 | 5 | require github.com/stretchr/testify v1.6.1 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 4 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 5 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 6 | github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= 7 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 8 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 9 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 10 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 11 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 12 | --------------------------------------------------------------------------------