├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── Gemfile ├── LICENSE ├── NOTICE ├── README.md ├── Rakefile ├── cache_rules.gemspec ├── lib ├── actions.rb ├── cache_rules.rb ├── formatting.rb ├── helpers.rb └── validations.rb └── test ├── helper.rb ├── test_cache_rules.rb ├── test_formatting.rb ├── test_helpers.rb ├── test_regressions.rb ├── test_tables.rb └── test_validations.rb /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | coverage/ 3 | 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: ruby 3 | rvm: 4 | - 2.0.0 5 | - 2.1.5 6 | - 2.2.0 7 | script: 8 | - gem install json -v 2.0.2 9 | - bundle exec rake test 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.5.0 (2015-05-04) 4 | 5 | * Add a check which verifies if a precondition exists before revalidating 6 | * Fixes issue #13 7 | * Add regression tests 8 | * Adjust one broken test 9 | 10 | ## 0.4.1 (2015-05-04) 11 | 12 | * Update README.md 13 | 14 | ## 0.4.0 (2015-05-03) 15 | 16 | * Modify 'revalidation' table so it doesn't check if the response is expired 17 | * Ensure validator matching works with '*' 18 | * Fix tests 19 | 20 | ## 0.3.0 (2015-05-03) 21 | 22 | * Make cache rules consistent based RFC spec 23 | * Add required 'cached' headers to tests 24 | 25 | ## 0.2.0 (2015-05-02) 26 | 27 | * Version bump 28 | 29 | ## 0.1.20 (2015-05-01) 30 | 31 | * Recently cached responses are cached correctly. Fixes issue #11 32 | 33 | ## 0.1.19 (2015-05-01) 34 | 35 | * The 'max-stale' header doesn't affect 'max-age' validation 36 | 37 | ## 0.1.18 (2015-05-01) 38 | 39 | * Ensure the 'Cache-Control: max-age' is validated 40 | * Add regression tests 41 | * Update documentation 42 | 43 | ## 0.1.17 (2015-04-30) 44 | 45 | * Return the proper 'Age' header and ignore the 'public' control directive 46 | 47 | ## 0.1.16 (2015-04-29) 48 | 49 | * Errors caused by empty HTTP headers. Fixes issue #8 50 | 51 | ## 0.1.15 (2015-04-29) 52 | 53 | * No max-stale = allow anything. Fixes issue #7 54 | 55 | ## 0.1.14 (2015-04-28) 56 | 57 | * Ensure Cache-Control is verified 58 | 59 | ## 0.1.13 (2015-04-28) 60 | 61 | * Don't cache the Host header 62 | 63 | ## 0.1.12 (2015-04-28) 64 | 65 | * Allow newer rubies 66 | 67 | ## 0.1.11 (2015-04-20) 68 | 69 | * Remove Gemfile.lock 70 | * Adjust Gemfile dependencies 71 | * Run travis-ci tests in container 72 | 73 | ## 0.1.10 (2015-04-20) 74 | 75 | * Remove gemspec post_install message 76 | 77 | ## 0.1.9 (2015-04-08) 78 | 79 | * Add regression tests for issue #5 80 | * Update Gemfile.lock / dependencies 81 | * Fix dates in this CHANGELOG 82 | 83 | ## 0.1.8 (2015-02-09) 84 | 85 | * Add tests to ensure URLs with query parameters are maintained 86 | 87 | ## 0.1.7 (2015-02-02) 88 | 89 | * Refactor and simplify `revalidate_response()` method 90 | 91 | ## 0.1.6 (2015-02-02) 92 | 93 | * Don't rescue ArgumentError on httpdate parse errors 94 | 95 | ## 0.1.5 (2015-02-02) 96 | 97 | * Closes #3. Returns all cached headers according to RFC 7234 sec4.3.4 98 | 99 | ## 0.1.4 (2015-02-01) 100 | 101 | * HTTP `Age` header is now returned as a String, but processed as an Integer 102 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | group :test do 4 | gem 'coveralls', require: false 5 | gem "rake" 6 | gem "fakeweb", "~> 1.3" 7 | gem 'minitest', '~> 5.5.0' 8 | gem 'minitest-reporters', '~> 1.0.0' 9 | gem 'simplecov' 10 | end 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License, version 2.0 2 | 3 | 1. Definitions 4 | 5 | 1.1. "Contributor" 6 | 7 | means each individual or legal entity that creates, contributes to the 8 | creation of, or owns Covered Software. 9 | 10 | 1.2. "Contributor Version" 11 | 12 | means the combination of the Contributions of others (if any) used by a 13 | Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | 17 | means Covered Software of a particular Contributor. 18 | 19 | 1.4. "Covered Software" 20 | 21 | means Source Code Form to which the initial Contributor has attached the 22 | notice in Exhibit A, the Executable Form of such Source Code Form, and 23 | Modifications of such Source Code Form, in each case including portions 24 | thereof. 25 | 26 | 1.5. "Incompatible With Secondary Licenses" 27 | means 28 | 29 | a. that the initial Contributor has attached the notice described in 30 | Exhibit B to the Covered Software; or 31 | 32 | b. that the Covered Software was made available under the terms of 33 | version 1.1 or earlier of the License, but not also under the terms of 34 | a Secondary License. 35 | 36 | 1.6. "Executable Form" 37 | 38 | means any form of the work other than Source Code Form. 39 | 40 | 1.7. "Larger Work" 41 | 42 | means a work that combines Covered Software with other material, in a 43 | separate file or files, that is not Covered Software. 44 | 45 | 1.8. "License" 46 | 47 | means this document. 48 | 49 | 1.9. "Licensable" 50 | 51 | means having the right to grant, to the maximum extent possible, whether 52 | at the time of the initial grant or subsequently, any and all of the 53 | rights conveyed by this License. 54 | 55 | 1.10. "Modifications" 56 | 57 | means any of the following: 58 | 59 | a. any file in Source Code Form that results from an addition to, 60 | deletion from, or modification of the contents of Covered Software; or 61 | 62 | b. any new file in Source Code Form that contains any Covered Software. 63 | 64 | 1.11. "Patent Claims" of a Contributor 65 | 66 | means any patent claim(s), including without limitation, method, 67 | process, and apparatus claims, in any patent Licensable by such 68 | Contributor that would be infringed, but for the grant of the License, 69 | by the making, using, selling, offering for sale, having made, import, 70 | or transfer of either its Contributions or its Contributor Version. 71 | 72 | 1.12. "Secondary License" 73 | 74 | means either the GNU General Public License, Version 2.0, the GNU Lesser 75 | General Public License, Version 2.1, the GNU Affero General Public 76 | License, Version 3.0, or any later versions of those licenses. 77 | 78 | 1.13. "Source Code Form" 79 | 80 | means the form of the work preferred for making modifications. 81 | 82 | 1.14. "You" (or "Your") 83 | 84 | means an individual or a legal entity exercising rights under this 85 | License. For legal entities, "You" includes any entity that controls, is 86 | controlled by, or is under common control with You. For purposes of this 87 | definition, "control" means (a) the power, direct or indirect, to cause 88 | the direction or management of such entity, whether by contract or 89 | otherwise, or (b) ownership of more than fifty percent (50%) of the 90 | outstanding shares or beneficial ownership of such entity. 91 | 92 | 93 | 2. License Grants and Conditions 94 | 95 | 2.1. Grants 96 | 97 | Each Contributor hereby grants You a world-wide, royalty-free, 98 | non-exclusive license: 99 | 100 | a. under intellectual property rights (other than patent or trademark) 101 | Licensable by such Contributor to use, reproduce, make available, 102 | modify, display, perform, distribute, and otherwise exploit its 103 | Contributions, either on an unmodified basis, with Modifications, or 104 | as part of a Larger Work; and 105 | 106 | b. under Patent Claims of such Contributor to make, use, sell, offer for 107 | sale, have made, import, and otherwise transfer either its 108 | Contributions or its Contributor Version. 109 | 110 | 2.2. Effective Date 111 | 112 | The licenses granted in Section 2.1 with respect to any Contribution 113 | become effective for each Contribution on the date the Contributor first 114 | distributes such Contribution. 115 | 116 | 2.3. Limitations on Grant Scope 117 | 118 | The licenses granted in this Section 2 are the only rights granted under 119 | this License. No additional rights or licenses will be implied from the 120 | distribution or licensing of Covered Software under this License. 121 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 122 | Contributor: 123 | 124 | a. for any code that a Contributor has removed from Covered Software; or 125 | 126 | b. for infringements caused by: (i) Your and any other third party's 127 | modifications of Covered Software, or (ii) the combination of its 128 | Contributions with other software (except as part of its Contributor 129 | Version); or 130 | 131 | c. under Patent Claims infringed by Covered Software in the absence of 132 | its Contributions. 133 | 134 | This License does not grant any rights in the trademarks, service marks, 135 | or logos of any Contributor (except as may be necessary to comply with 136 | the notice requirements in Section 3.4). 137 | 138 | 2.4. Subsequent Licenses 139 | 140 | No Contributor makes additional grants as a result of Your choice to 141 | distribute the Covered Software under a subsequent version of this 142 | License (see Section 10.2) or under the terms of a Secondary License (if 143 | permitted under the terms of Section 3.3). 144 | 145 | 2.5. Representation 146 | 147 | Each Contributor represents that the Contributor believes its 148 | Contributions are its original creation(s) or it has sufficient rights to 149 | grant the rights to its Contributions conveyed by this License. 150 | 151 | 2.6. Fair Use 152 | 153 | This License is not intended to limit any rights You have under 154 | applicable copyright doctrines of fair use, fair dealing, or other 155 | equivalents. 156 | 157 | 2.7. Conditions 158 | 159 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in 160 | Section 2.1. 161 | 162 | 163 | 3. Responsibilities 164 | 165 | 3.1. Distribution of Source Form 166 | 167 | All distribution of Covered Software in Source Code Form, including any 168 | Modifications that You create or to which You contribute, must be under 169 | the terms of this License. You must inform recipients that the Source 170 | Code Form of the Covered Software is governed by the terms of this 171 | License, and how they can obtain a copy of this License. You may not 172 | attempt to alter or restrict the recipients' rights in the Source Code 173 | Form. 174 | 175 | 3.2. Distribution of Executable Form 176 | 177 | If You distribute Covered Software in Executable Form then: 178 | 179 | a. such Covered Software must also be made available in Source Code Form, 180 | as described in Section 3.1, and You must inform recipients of the 181 | Executable Form how they can obtain a copy of such Source Code Form by 182 | reasonable means in a timely manner, at a charge no more than the cost 183 | of distribution to the recipient; and 184 | 185 | b. You may distribute such Executable Form under the terms of this 186 | License, or sublicense it under different terms, provided that the 187 | license for the Executable Form does not attempt to limit or alter the 188 | recipients' rights in the Source Code Form under this License. 189 | 190 | 3.3. Distribution of a Larger Work 191 | 192 | You may create and distribute a Larger Work under terms of Your choice, 193 | provided that You also comply with the requirements of this License for 194 | the Covered Software. If the Larger Work is a combination of Covered 195 | Software with a work governed by one or more Secondary Licenses, and the 196 | Covered Software is not Incompatible With Secondary Licenses, this 197 | License permits You to additionally distribute such Covered Software 198 | under the terms of such Secondary License(s), so that the recipient of 199 | the Larger Work may, at their option, further distribute the Covered 200 | Software under the terms of either this License or such Secondary 201 | License(s). 202 | 203 | 3.4. Notices 204 | 205 | You may not remove or alter the substance of any license notices 206 | (including copyright notices, patent notices, disclaimers of warranty, or 207 | limitations of liability) contained within the Source Code Form of the 208 | Covered Software, except that You may alter any license notices to the 209 | extent required to remedy known factual inaccuracies. 210 | 211 | 3.5. Application of Additional Terms 212 | 213 | You may choose to offer, and to charge a fee for, warranty, support, 214 | indemnity or liability obligations to one or more recipients of Covered 215 | Software. However, You may do so only on Your own behalf, and not on 216 | behalf of any Contributor. You must make it absolutely clear that any 217 | such warranty, support, indemnity, or liability obligation is offered by 218 | You alone, and You hereby agree to indemnify every Contributor for any 219 | liability incurred by such Contributor as a result of warranty, support, 220 | indemnity or liability terms You offer. You may include additional 221 | disclaimers of warranty and limitations of liability specific to any 222 | jurisdiction. 223 | 224 | 4. Inability to Comply Due to Statute or Regulation 225 | 226 | If it is impossible for You to comply with any of the terms of this License 227 | with respect to some or all of the Covered Software due to statute, 228 | judicial order, or regulation then You must: (a) comply with the terms of 229 | this License to the maximum extent possible; and (b) describe the 230 | limitations and the code they affect. Such description must be placed in a 231 | text file included with all distributions of the Covered Software under 232 | this License. Except to the extent prohibited by statute or regulation, 233 | such description must be sufficiently detailed for a recipient of ordinary 234 | skill to be able to understand it. 235 | 236 | 5. Termination 237 | 238 | 5.1. The rights granted under this License will terminate automatically if You 239 | fail to comply with any of its terms. However, if You become compliant, 240 | then the rights granted under this License from a particular Contributor 241 | are reinstated (a) provisionally, unless and until such Contributor 242 | explicitly and finally terminates Your grants, and (b) on an ongoing 243 | basis, if such Contributor fails to notify You of the non-compliance by 244 | some reasonable means prior to 60 days after You have come back into 245 | compliance. Moreover, Your grants from a particular Contributor are 246 | reinstated on an ongoing basis if such Contributor notifies You of the 247 | non-compliance by some reasonable means, this is the first time You have 248 | received notice of non-compliance with this License from such 249 | Contributor, and You become compliant prior to 30 days after Your receipt 250 | of the notice. 251 | 252 | 5.2. If You initiate litigation against any entity by asserting a patent 253 | infringement claim (excluding declaratory judgment actions, 254 | counter-claims, and cross-claims) alleging that a Contributor Version 255 | directly or indirectly infringes any patent, then the rights granted to 256 | You by any and all Contributors for the Covered Software under Section 257 | 2.1 of this License shall terminate. 258 | 259 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user 260 | license agreements (excluding distributors and resellers) which have been 261 | validly granted by You or Your distributors under this License prior to 262 | termination shall survive termination. 263 | 264 | 6. Disclaimer of Warranty 265 | 266 | Covered Software is provided under this License on an "as is" basis, 267 | without warranty of any kind, either expressed, implied, or statutory, 268 | including, without limitation, warranties that the Covered Software is free 269 | of defects, merchantable, fit for a particular purpose or non-infringing. 270 | The entire risk as to the quality and performance of the Covered Software 271 | is with You. Should any Covered Software prove defective in any respect, 272 | You (not any Contributor) assume the cost of any necessary servicing, 273 | repair, or correction. This disclaimer of warranty constitutes an essential 274 | part of this License. No use of any Covered Software is authorized under 275 | this License except under this disclaimer. 276 | 277 | 7. Limitation of Liability 278 | 279 | Under no circumstances and under no legal theory, whether tort (including 280 | negligence), contract, or otherwise, shall any Contributor, or anyone who 281 | distributes Covered Software as permitted above, be liable to You for any 282 | direct, indirect, special, incidental, or consequential damages of any 283 | character including, without limitation, damages for lost profits, loss of 284 | goodwill, work stoppage, computer failure or malfunction, or any and all 285 | other commercial damages or losses, even if such party shall have been 286 | informed of the possibility of such damages. This limitation of liability 287 | shall not apply to liability for death or personal injury resulting from 288 | such party's negligence to the extent applicable law prohibits such 289 | limitation. Some jurisdictions do not allow the exclusion or limitation of 290 | incidental or consequential damages, so this exclusion and limitation may 291 | not apply to You. 292 | 293 | 8. Litigation 294 | 295 | Any litigation relating to this License may be brought only in the courts 296 | of a jurisdiction where the defendant maintains its principal place of 297 | business and such litigation shall be governed by laws of that 298 | jurisdiction, without reference to its conflict-of-law provisions. Nothing 299 | in this Section shall prevent a party's ability to bring cross-claims or 300 | counter-claims. 301 | 302 | 9. Miscellaneous 303 | 304 | This License represents the complete agreement concerning the subject 305 | matter hereof. If any provision of this License is held to be 306 | unenforceable, such provision shall be reformed only to the extent 307 | necessary to make it enforceable. Any law or regulation which provides that 308 | the language of a contract shall be construed against the drafter shall not 309 | be used to construe this License against a Contributor. 310 | 311 | 312 | 10. Versions of the License 313 | 314 | 10.1. New Versions 315 | 316 | Mozilla Foundation is the license steward. Except as provided in Section 317 | 10.3, no one other than the license steward has the right to modify or 318 | publish new versions of this License. Each version will be given a 319 | distinguishing version number. 320 | 321 | 10.2. Effect of New Versions 322 | 323 | You may distribute the Covered Software under the terms of the version 324 | of the License under which You originally received the Covered Software, 325 | or under the terms of any subsequent version published by the license 326 | steward. 327 | 328 | 10.3. Modified Versions 329 | 330 | If you create software not governed by this License, and you want to 331 | create a new license for such software, you may create and use a 332 | modified version of this License if you rename the license and remove 333 | any references to the name of the license steward (except to note that 334 | such modified license differs from this License). 335 | 336 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 337 | Licenses If You choose to distribute Source Code Form that is 338 | Incompatible With Secondary Licenses under the terms of this version of 339 | the License, the notice described in Exhibit B of this License must be 340 | attached. 341 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Regex parsing borrowed from: https://github.com/hapijs/wreck 2 | 3 | Copyright (c) 2012-2014, Walmart and other contributors. 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | * Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | * The names of any contributors may not be used to endorse or promote 14 | products derived from this software without specific prior written 15 | permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 18 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 19 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 20 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS AND CONTRIBUTORS BE LIABLE FOR ANY 21 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 22 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 23 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 24 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 25 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 26 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## What is CacheRules 2 | 3 | _CacheRules_ is a well-behaved HTTP caching library aimed at being [RFC 7234](https://tools.ietf.org/html/rfc7234) compliant. 4 | 5 | This library does **not actually _cache_ anything**, and it is **not a _proxy_**. 6 | It validates HTTP headers and returns the appropriate response to determine 7 | if a request can be served from the cache. 8 | 9 | It is up to the HTTP Cache implementation to store the cached results 10 | and serve responses from the cache if necessary. 11 | 12 | [![Build Status](https://travis-ci.org/aw/CacheRules.svg?branch=master)](https://travis-ci.org/aw/CacheRules) [![Coverage Status](https://coveralls.io/repos/aw/CacheRules/badge.svg?branch=master)](https://coveralls.io/r/aw/CacheRules?branch=master) [![Gem Version](https://badge.fury.io/rb/cache_rules.svg)](http://badge.fury.io/rb/cache_rules) 13 | 14 | ## Getting started 15 | 16 | Add this line to your Gemfile: `gem 'cache_rules'` 17 | 18 | or 19 | 20 | Install with: `gem install cache_rules` 21 | 22 | ## Usage 23 | 24 | There is only 1 _public API call_ when using this library: `validate()`. 25 | 26 | ```ruby 27 | require 'cache_rules' 28 | 29 | # test without cached response 30 | url = 'https://status.rubygems.org' 31 | request = {'Version' => 'HTTP/1.1'} 32 | cached = {} 33 | 34 | CacheRules.validate url, request, cached 35 | 36 | => {:body=>nil, :code=>307, :headers=>{"Cache-Lookup"=>"MISS", "Location"=>"https://status.rubygems.org"}} 37 | 38 | # test with cached response (status code 200 because no ETag or If-None-Match supplied) 39 | cached = { "Date" => {"timestamp"=>1420095825}, "X-Cache-Req-Date" => {"timestamp"=>1420268625}, "X-Cache-Res-Date" => {"timestamp"=>1420268625} } 40 | 41 | CacheRules.validate url, request, cached 42 | 43 | => {:body=>"stale", :code=>200, :headers=>{"Date"=>"Wed, 21 Feb 2018 05:09:27 GMT", "Age"=>"99094242", "Warning"=>"110 - \"Response is Stale\"", "Cache-Lookup"=>"STALE"}} 44 | 45 | # test with cached response (status code 304 because If-None-Match supplied) 46 | request = {"Version"=>"HTTP/1.1", "If-None-Match"=>"*"} 47 | cached = { "Date" => {"timestamp"=>1519190160}, "X-Cache-Req-Date" => {"timestamp"=>1519190160}, "X-Cache-Res-Date" => {"timestamp"=>1519190160} } 48 | 49 | CacheRules.validate url, request, cached 50 | 51 | => {:body=>nil, :code=>304, :headers=>{"Date"=>"Wed, 21 Feb 2018 05:15:01 GMT", "Age"=>"241", "Warning"=>"110 - \"Response is Stale\"", "Cache-Lookup"=>"STALE"}} 52 | ``` 53 | 54 | The `request` headers must be a Ruby Hash or Array of 2-element Arrays. 55 | 56 | The `cached` headers must already have been normalized by this caching library, i.e: they **must** include array keys: 57 | 58 | * `Date['timestamp']` 59 | * `X-Cache-Req-Date['timestamp']` 60 | * `X-Cache-Res-Date['timestamp']` 61 | 62 | See `test/` directory for more examples. 63 | 64 | ## Decision tables 65 | 66 | There are two decision tables to help figure out how to process each type of 67 | HTTP Caching request. 68 | 69 | ### Request/Cache Table 70 | 71 | ![cached](https://cloud.githubusercontent.com/assets/153401/7445031/9d64eb2c-f190-11e4-8938-44a443f9bec3.png) 72 | 73 | ### Revalidation Table 74 | 75 | ![revalidation](https://cloud.githubusercontent.com/assets/153401/7445040/09c747ba-f191-11e4-8b9c-a8b26709fb99.png) 76 | 77 | ## RFC compliance 78 | 79 | This HTTP Caching library aims to be RFC 7230-7235 compliant. It is a best 80 | effort attempt to correctly interpret these documents. Some errors may exist, 81 | so please [notify me](https://github.com/aw/CacheRules/issues/new) if something isn't processed correctly according to the RFCs. 82 | 83 | ### Feature list 84 | 85 | * Normalizing header names and field values (ex: `Last-Modified`) 86 | * Ensuring date fields are correctly formatted (ex: `Fri, 31 Dec 1999 23:59:59 GMT`) 87 | * Merging duplicate header fields 88 | * Interop with HTTP/1.0 clients (ex: `Pragma: no-cache`) 89 | * Weak entity-tag matching (ex: `If-None-Match: "W/abc123"`) 90 | * Last modified date matching (ex: `If-Modified-Since: Thu, 01 Jan 2015 07:03:45 GMT`) 91 | * Various header validation including Cache-Control headers 92 | * Cache-Control directives with quoted strings (ex: `no-cache="Cookie"`) 93 | * Removing non-cacheable headers (ex: `Authorization`) 94 | * Correctly calculating freshness and current age of cached responses 95 | * Explicit and Heuristic freshness calculation 96 | * Returning 110 and 111 Warning headers when serving stale responses 97 | * Revalidating expired responses with the origin server (using HEAD) 98 | * Returning the correct status code based on validation/revalidation results 99 | * Lots more little things sprinkled throughout the RFCs... 100 | * Written in purely functional Ruby (mostly) with 100% unit/integration test coverage 101 | 102 | ## Custom headers 103 | 104 | Custom headers are generated to help with testing and compliance validation. 105 | 106 | These are somewhat based on [CloudFlare's](https://support.cloudflare.com/hc/en-us/articles/200168266-What-do-the-various-CloudFlare-cache-responses-HIT-Expired-etc-mean-) cache headers: 107 | 108 | * `Cache-Lookup: HIT`: resource is in cache and still valid. Serving from the cache. 109 | * `Cache-Lookup: MISS`: resource is not in cache. Redirecting to the origin server. 110 | * `Cache-Lookup: EXPIRED`: resource is in cache, but expired. Redirecting to the origin server or serving an error message. 111 | * `Cache-Lookup: STALE`: resource is in cache and expired, but the origin server wasn't contacted successfully to revalidate the request. Serving stale response from the cache. 112 | * `Cache-Lookup: REVALIDATED`: resource is in cache, was expired, but was revalidated successfully at the origin server. Serving from the cache. 113 | 114 | ## Tests 115 | 116 | To run the tests, type: 117 | 118 | `bundle exec rake test` 119 | 120 | ## TODO 121 | 122 | * Validation of `s-maxage` [response header](https://tools.ietf.org/html/rfc7234#section-5.2.2.9) 123 | * Handling `Vary` header and different representations for the same resource 124 | * Handling `206 (Partial)` and `Range` headers for resuming downloads 125 | * Handling `Cache-Control: private` headers 126 | * Caching other _cacheable_ responses such as [404 and 501](https://tools.ietf.org/html/rfc7231#section-6.1) 127 | 128 | ## What is C.R.E.A.M. ? 129 | 130 | C.R.E.A.M. is an influencial lyrical masterpiece from the 90s performed by the [Wu-Tang Clan](https://www.youtube.com/watch?v=PBwAxmrE194) 131 | 132 | It's also the premise of this [troll video](https://player.vimeo.com/video/14782834?loop=1;&autoplay=1;) 133 | 134 | ## Further reading 135 | 136 | Some useful articles explaining HTTP Caching: 137 | 138 | * [Caching is hard, draw me a picture](http://www.bizcoder.com/caching-is-hard-draw-me-a-picture) 139 | * [A Beginner's Guide to HTTP Cache Headers](http://www.mobify.com/blog/beginners-guide-to-http-cache-headers/) 140 | 141 | ## LICENSE 142 | 143 | This Source Code Form is subject to the terms of the Mozilla Public 144 | License, v. 2.0. If a copy of the MPL was not distributed with this 145 | file, You can obtain one at [http://mozilla.org/MPL/2.0/](http://mozilla.org/MPL/2.0/). 146 | 147 | Copyright (c) 2014-2018 Alexander Williams, Unscramble 148 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | desc "Run the tests" 2 | task :test do 3 | run_test 4 | end 5 | 6 | def run_test 7 | require 'rake/testtask' 8 | 9 | Rake::TestTask.new do |t| 10 | t.libs << "test" 11 | t.test_files = FileList['test/helper.rb', 'test/test_*.rb'] 12 | end 13 | 14 | end 15 | -------------------------------------------------------------------------------- /cache_rules.gemspec: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env gem build 2 | # encoding: utf-8 3 | 4 | require "base64" 5 | require 'date' 6 | 7 | Gem::Specification.new do |s| 8 | s.name = 'cache_rules' 9 | s.version = '0.5.0' 10 | 11 | s.date = Date.today.to_s 12 | 13 | s.summary = "CacheRules validates requests and responses for cached HTTP data based on RFCs 7230-7235" 14 | s.description = "#{s.summary}. The goal is to facilitate implementation of well-behaved caching solutions which adhere to RFC standards." 15 | 16 | s.author = 'Alexander Williams' 17 | s.email = Base64.decode64("YXdpbGxpYW1zQGFsZXh3aWxsaWFtcy5jYQ==\n") 18 | 19 | s.homepage = 'https://github.com/aw/CacheRules' 20 | 21 | s.require_paths = ["lib"] 22 | s.files = `git ls-files`.split("\n") 23 | 24 | # Tests 25 | s.add_development_dependency "fakeweb", '~> 1.3' 26 | s.add_development_dependency 'minitest', '~> 5.5.0' 27 | s.add_development_dependency 'minitest-reporters', '~> 1.0.0' 28 | s.add_development_dependency 'simplecov' 29 | 30 | s.licenses = ['MPL-2.0'] 31 | s.required_ruby_version = ::Gem::Requirement.new(">= 1.9") 32 | end 33 | -------------------------------------------------------------------------------- /lib/actions.rb: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | # 5 | # Copyright (c) 2014-2016 Alexander Williams, Unscramble 6 | 7 | module CacheRules 8 | extend self 9 | 10 | # HTTP Header Actions 11 | 12 | def action_revalidate(result) 13 | result[:value] 14 | end 15 | 16 | # Generate an age equal to the current cached entry's age 17 | # source: https://tools.ietf.org/html/rfc7234#section-4 18 | def action_add_age(result) 19 | current_age = helper_current_age Time.now.gmtime.to_i, result[:cached] 20 | 21 | {'Age' => current_age.to_s} 22 | end 23 | 24 | def action_add_x_cache(result) 25 | {'Cache-Lookup' => result[:value]} 26 | end 27 | 28 | def action_add_warning(result) 29 | {'Warning' => result[:value]} 30 | end 31 | 32 | def action_add_status(result) 33 | result[:value] 34 | end 35 | 36 | def action_return_body(result) 37 | result[:value] 38 | end 39 | 40 | end 41 | -------------------------------------------------------------------------------- /lib/cache_rules.rb: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | # 5 | # Copyright (c) 2014-2016 Alexander Williams, Unscramble 6 | # 7 | # Original Source: https://github.com/aw/CacheRules 8 | # 9 | # 10 | # This library validates requests and responses for cached HTTP data. 11 | # 12 | # Rules based on RFCs 7230-7235 - https://tools.ietf.org/wg/httpbis/ 13 | # 14 | # Usage: 15 | # => CacheRules.validate(url, request_headers, cached_headers) 16 | # 17 | # If you grew up on the crime side: 18 | # => CacheRules::Everything::Around::Me.validate(url, request_headers, cached_headers) 19 | 20 | require 'net/http' 21 | require 'date' 22 | require 'time' 23 | require 'uri' 24 | 25 | require 'actions.rb' 26 | require 'formatting.rb' 27 | require 'helpers.rb' 28 | require 'validations.rb' 29 | 30 | module CacheRules 31 | extend self 32 | 33 | HEADERS_NO_CACHE = %w( 34 | Set-Cookie Cookie 35 | Accept-Ranges Range If-Range Content-Range 36 | Referer From Host 37 | Authorization Proxy-Authorization 38 | ) 39 | 40 | HEADERS_HTTPDATE = %w( 41 | Last-Modified If-Modified-Since If-Unmodified-Since 42 | Expires Date 43 | X-Cache-Req-Date X-Cache-Res-Date 44 | ) 45 | 46 | HEADERS_CSV = %w( 47 | Connection Trailer Transfer-Encoding Upgrade Via 48 | Accept Accept-Charset Accept-Encoding Accept-Language Allow 49 | Content-Encoding Content-Language Vary 50 | Cache-Control Warning Pragma If-Match If-None-Match 51 | ) 52 | 53 | HEADERS_NUMBER = %w( 54 | Age Content-Length Max-Forwards 55 | ) 56 | 57 | OPTIONS_CACHE = HEADERS_CSV.select {|header| header == 'Cache-Control' } 58 | OPTIONS_CSV = HEADERS_CSV.reject {|header| header == 'Cache-Control' } 59 | OPTIONS_RETRY = %w(Retry-After) 60 | 61 | X = nil 62 | 63 | # Decision table for request/cached headers 64 | REQUEST_TABLE = { 65 | :conditions => { 66 | 'cached' => [0, 0, 1, 1, 1, 1, 1, 1, 1], 67 | 'must_revalidate' => [X, X, X, X, 0, 0, 0, 1, X], 68 | 'no_cache' => [X, X, 0, 0, 0, 0, 0, 0, 1], 69 | 'precond_match' => [X, X, 0, 1, 0, 1, X, X, X], 70 | 'expired' => [X, X, 0, 0, 1, 1, 1, 1, X], 71 | 'only_if_cached' => [0, 1, X, X, X, X, X, X, X], 72 | 'allow_stale' => [X, X, X, X, 1, 1, 0, X, X] 73 | }, 74 | :actions => { 75 | 'revalidate' => [X, X, X, X, X, X, X, 1, 1], 76 | 'add_age' => [X, X, 1, 1, 1, 1], 77 | 'add_x_cache' => %w(MISS MISS HIT HIT STALE STALE EXPIRED), 78 | 'add_warning' => [X, X, X, X, '110 - "Response is Stale"', '110 - "Response is Stale"'], 79 | 'add_status' => [307, 504, 200, 304, 200, 304, 504], 80 | 'return_body' => [X, 'Gateway Timeout', 'cached', X, 'stale', X, 'Gateway Timeout'] 81 | } 82 | } 83 | 84 | # Decision table for revalidated responses 85 | RESPONSE_TABLE = { 86 | :conditions => { 87 | 'is_error' => [0, 0, 1, 1, 1], 88 | 'allow_stale' => [X, X, 0, 1, 1], 89 | 'validator_match' => [0, 1, X, 0, 1] 90 | }, 91 | :actions => { 92 | 'revalidate' => [], 93 | 'add_age' => [1, 1, X, 1, 1], 94 | 'add_x_cache' => %w(REVALIDATED REVALIDATED EXPIRED STALE STALE), 95 | 'add_warning' => [X, X, X, '111 - "Revalidation Failed"', '111 - "Revalidation Failed"'], 96 | 'add_status' => [200, 304, 504, 200, 304], 97 | 'return_body' => ['cached', X, 'Gateway Timeout', 'stale'] 98 | } 99 | } 100 | 101 | # Build the map tables in advance for faster lookups i.e: O(1) 102 | REQUEST_MAP = helper_table_map(REQUEST_TABLE[:conditions]) 103 | RESPONSE_MAP = helper_table_map(RESPONSE_TABLE[:conditions]) 104 | 105 | # Public: Validate a URL and the request/cached/response headers 106 | # TODO: validate the required parameters to ensure they are set correctly 107 | def validate(url, request_headers, cached_headers = {}) 108 | # 1. normalize the request headers 109 | normalized_headers = normalize.call request_headers 110 | actions = REQUEST_TABLE[:actions] 111 | 112 | # 2. get the column matching the request headers 113 | column = REQUEST_MAP[helper_run_validate.call(REQUEST_TABLE[:conditions], normalized_headers, cached_headers).join] 114 | response = Proc.new { helper_response url, actions, column, cached_headers } 115 | revalidate = Proc.new { revalidate_response url, normalized_headers, cached_headers } 116 | 117 | # 3. return the response or revalidate 118 | actions['revalidate'][column] == 1 ? revalidate.call : response.call 119 | end 120 | 121 | # Revalidates a response by fetching headers from the origin server 122 | def revalidate_response(*args) 123 | url, request, cached = *args 124 | has_preconditions = helper_has_preconditions.(request, cached) 125 | 126 | # 1. get the column 127 | column = if has_preconditions 128 | res_headers = helper_response_headers.(helper_make_request_timer.(args)) 129 | RESPONSE_MAP[helper_run_validate.call(RESPONSE_TABLE[:conditions], request, cached, res_headers).join] 130 | else 131 | res_headers = {} 132 | 2 # return column 2 (504 EXPIRED) 133 | end 134 | 135 | # 2. return the response 136 | helper_response url, RESPONSE_TABLE[:actions], column, cached, res_headers 137 | rescue => error 138 | {:code => 504, :body => 'Gateway Timeout', :headers => [], :error => error.message, :debug => error} 139 | end 140 | 141 | # Returns a net/http response Object 142 | def make_request 143 | ->(url, request_headers, cached_headers) { 144 | uri = URI.parse url 145 | http = Net::HTTP.new uri.host, uri.port 146 | http.open_timeout = 2 147 | http.read_timeout = 60 148 | http.use_ssl = uri.scheme == 'https' 149 | http.verify_mode = OpenSSL::SSL::VERIFY_PEER 150 | 151 | request = Net::HTTP::Head.new uri.request_uri 152 | 153 | # Two possible validators: entity tags and timestamp 154 | # source: https://tools.ietf.org/html/rfc7234#section-4.3.1 155 | entity_tags = Proc.new { helper_combine_etags request_headers, cached_headers }.call 156 | timestamp = Proc.new { helper_timestamp request_headers, cached_headers } 157 | 158 | # Set the precondition header before making the request 159 | request['If-None-Match'] = entity_tags if entity_tags 160 | ts = timestamp.call unless entity_tags 161 | request['If-Modified-Since'] = ts if ts && !entity_tags 162 | 163 | # Make the HTTP(s) request 164 | helper_make_request http, request 165 | } 166 | end 167 | 168 | end 169 | 170 | module CacheRules 171 | module Everything 172 | module Around 173 | module Me 174 | extend CacheRules 175 | 176 | # C.R.E.A.M. 177 | def self.get_the_money 178 | "Dolla Dolla Bill Y'all" 179 | end 180 | 181 | end 182 | end 183 | end 184 | end 185 | -------------------------------------------------------------------------------- /lib/formatting.rb: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | # 5 | # Copyright (c) 2014-2016 Alexander Williams, Unscramble 6 | 7 | module CacheRules 8 | extend self 9 | 10 | # HTTP Header Formatting 11 | 12 | # Create a normalized Hash of HTTP headers 13 | def normalize 14 | ->(headers) { 15 | Hash[normalize_fields.(combine.(clean.(Array(headers).map &format_key)))] 16 | } 17 | end 18 | 19 | # Format the key to look like this: Last-Modified 20 | def format_key 21 | Proc.new {|key, value| 22 | k = key.downcase == 'etag' ? 'ETag' : key.split('-').map(&:capitalize).join('-') 23 | [ k, value ] 24 | } 25 | end 26 | 27 | # Intentionally drop these headers to avoid caching them 28 | # If-Modified-Since should be dropped if the date isn't valid 29 | # source: https://tools.ietf.org/html/rfc7232#section-3.3 30 | def clean 31 | ->(headers) { 32 | Array(headers).reject {|key, value| 33 | HEADERS_NO_CACHE.include?(key) || helper_is_if_modified_error?(key, value) || value.nil? || value.empty? 34 | } 35 | } 36 | end 37 | 38 | # Combine headers with a comma if the field-names are duplicate 39 | def combine 40 | ->(headers) { 41 | Array(headers).group_by {|h, _| h }.map {|k, v| 42 | v = HEADERS_CSV.include?(k) ? v.map {|_, x| x }.join(', ') : v[0][1] # OPTIMIZE 43 | [ k, v ] 44 | } 45 | } 46 | end 47 | 48 | # Normalizes the value (field-value) of each header 49 | def normalize_fields 50 | ->(headers) { 51 | Array(headers).map &format_field 52 | } 53 | end 54 | 55 | # Returns a Hash of Strings 56 | def unnormalize_fields 57 | ->(headers) { 58 | Array(headers).reduce({}) {|hash, (key, value)| 59 | hash.merge Hash[[format_field.call(key, value, true)]] 60 | } 61 | } 62 | end 63 | 64 | # Returns a Hash, Array, Integer or String based on the supplied arguments 65 | def format_field 66 | Proc.new {|key, header, stringify| 67 | f = format_value header, stringify 68 | 69 | value = case key 70 | when *HEADERS_HTTPDATE then f.call('httpdate') # => Hash 71 | when *OPTIONS_CACHE then f.call('cache_control') # => Array 72 | when *OPTIONS_CSV then f.call('csv') # => Array 73 | when *OPTIONS_RETRY then f.call('retry_after') # => Hash or Integer 74 | else header # => String 75 | end 76 | [ key, value ] 77 | } 78 | end 79 | 80 | # Returns the value of the field 81 | def format_value(header, stringify = nil) 82 | Proc.new {|field| 83 | stringify ? send("#{ field }_string", header) : send("#{ field }", header) 84 | } 85 | end 86 | 87 | def httpdate(header) 88 | timestamp = httpdate_helper header 89 | 90 | { 91 | 'httpdate' => Time.at(timestamp).gmtime.httpdate, 92 | 'timestamp' => timestamp 93 | } 94 | end 95 | 96 | def httpdate_string(header) 97 | timestamp = httpdate_helper header['httpdate'] 98 | 99 | Time.at(timestamp).gmtime.httpdate 100 | end 101 | 102 | # Correctly parse the 3 Date/Time formats and convert to GMT 103 | # source: https://tools.ietf.org/html/rfc7234#section-4.2 104 | def httpdate_helper(header) 105 | # source: https://tools.ietf.org/html/rfc7231#section-7.1.1.1 106 | DateTime.parse(header).to_time.to_i 107 | rescue => e 108 | # If the supplied date is invalid, use a time in the past (5 minutes ago) 109 | # source: https://tools.ietf.org/html/rfc7234#section-5.3 110 | Time.now.gmtime.to_i - 300 111 | end 112 | 113 | # OPTIMIZE: this regex is copied from JavaScript, could be greatly simplified 114 | # Returns a Hash with the directive as key, token (or nil), quoted-string (or nil) 115 | def cache_control(header = '') 116 | result = header.scan /(?:^|(?:\s*\,\s*))([^\x00-\x20\(\)<>@\,;\:\\"\/\[\]\?\=\{\}\x7F]+)(?:\=(?:([^\x00-\x20\(\)<>@\,;\:\\"\/\[\]\?\=\{\}\x7F]+)|(?:\"((?:[^"\\]|\\.)*)\")))?/ 117 | result.reduce({}) {|hash, x| 118 | hash.merge({ 119 | x[0].downcase => { 120 | 'token' => x[1], 121 | 'quoted_string' => x[2] 122 | } 123 | }) 124 | } 125 | end 126 | 127 | # Parses the Cache-Control header and returns a comma-separated String 128 | def cache_control_string(header) 129 | Array(header).map {|x| 130 | token = x[1]['token'] 131 | quote = x[1]['quoted_string'] 132 | directive = x[0] 133 | 134 | if token && quote.nil? then "#{ directive }=#{ token }" 135 | elsif token.nil? && quote then "#{ directive }=\"#{ quote }\"" 136 | else directive 137 | end 138 | }.join ', ' 139 | end 140 | 141 | def csv(header = '') 142 | header.split(',').map(&:strip) 143 | end 144 | 145 | def csv_string(header) 146 | Array(header).join ', ' 147 | end 148 | 149 | # "The value of this field can be either an HTTP-date or a number of seconds..." 150 | # source: https://tools.ietf.org/html/rfc7234#section-7.1.3 151 | def retry_after(header) 152 | Integer(header).abs 153 | rescue => e 154 | httpdate header 155 | end 156 | 157 | def retry_after_string(header) 158 | header.to_s 159 | end 160 | 161 | end 162 | -------------------------------------------------------------------------------- /lib/helpers.rb: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | # 5 | # Copyright (c) 2014-2016 Alexander Williams, Unscramble 6 | 7 | module CacheRules 8 | extend self 9 | 10 | # Create a map with all possible combinations 11 | def helper_table_map(conditions) 12 | (2**conditions.length).times.map(&helper_row_col_hash(conditions)).reduce(:merge) 13 | end 14 | 15 | # Returns a hash representing a row/column, for the table map 16 | def helper_row_col_hash(conditions) 17 | Proc.new {|index| 18 | row = helper_bit_string conditions.length, index 19 | col = helper_parse_conditions({:conditions => conditions, :answers => row.chars.map(&:to_i)}) 20 | 21 | {row => col} 22 | } 23 | end 24 | 25 | # Returns a string of 0s and 1s 26 | def helper_bit_string(num_conditions, index) 27 | index.to_s(2).rjust num_conditions, '0' 28 | end 29 | 30 | # Returns the matching column number, or nil 31 | def helper_parse_conditions(table) 32 | # Loop through each answer and hope to end up with the exact column match 33 | result = table[:answers].each_index.map(&helper_loop_conditions(table)).reduce(:&).compact 34 | result[0] if result.length == 1 35 | end 36 | 37 | # Loop through each condition and see if the answer matches 38 | def helper_loop_conditions(table) 39 | Proc.new {|index| 40 | table[:conditions].values[index].map.each_with_index {|x, i| 41 | i if x == table[:answers][index] || x.nil? 42 | } 43 | } 44 | end 45 | 46 | # Returns a bit Array of answers for the conditions 47 | def helper_run_validate 48 | Proc.new {|table, request, cached, response| 49 | table.keys.map {|x| 50 | headers = {:request => request, :cached => cached, :response => response} 51 | send("validate_#{ x }?", headers) 52 | } 53 | } 54 | end 55 | 56 | # Returns an Array of actions to be performed based on the column number 57 | def helper_run_action(actions, column, cached) 58 | actions.map {|key, value| 59 | send("action_#{ key }", {:value => value[column], :cached => cached}) unless value[column].nil? 60 | } 61 | end 62 | 63 | # Returns the response body, code and headers based on the actions results 64 | def helper_response(url, actions, column, cached, response = {}) 65 | _, age, x_cache, warning, status, body = helper_run_action actions, column, cached 66 | 67 | normalized = normalize.call response 68 | headers_304 = helper_headers_200_304.call(cached, normalized) if status == 200 || status == 304 69 | headers_url = {'Location' => url} if status == 307 70 | 71 | headers = [headers_304, age, warning, x_cache, headers_url].compact.reduce &:merge 72 | 73 | {:body => body, :code => status, :headers => headers} 74 | end 75 | 76 | # Returns a Boolean after trying to parse the If-Modified-Since, or nil 77 | def helper_is_if_modified_error?(key, value) 78 | if key == 'If-Modified-Since' 79 | begin 80 | false if DateTime.parse(value) 81 | rescue ArgumentError => e 82 | true 83 | end 84 | end 85 | end 86 | 87 | # Generate the same headers if they exist for 200/304 responses 88 | # source: https://tools.ietf.org/html/rfc7232#section-4.1 89 | def helper_headers_200_304 90 | Proc.new {|cached, response| 91 | new_headers = response.select &helper_remove_warning_1xx 92 | unnormalize_fields.call cached.merge(new_headers).reject {|key, _| 93 | key == 'X-Cache-Req-Date' || key == 'X-Cache-Res-Date' || key == 'Status' 94 | } 95 | } 96 | end 97 | 98 | # delete 1xx Warning headers 99 | # source: https://tools.ietf.org/html/rfc7234#section-4.3.4 100 | def helper_remove_warning_1xx 101 | Proc.new {|key, value| 102 | {key => value} unless key == 'Warning' && value.reject! {|x| x =~ /^1\d{2}/ } && value.length == 0 103 | } 104 | end 105 | 106 | # Header can be a String or Array 107 | def helper_has_star(header) 108 | header && header.include?("*") 109 | end 110 | 111 | # Combine entity tags if they exist 112 | # source: https://tools.ietf.org/html/rfc7234#section-4.3.2 113 | def helper_combine_etags(request, cached) 114 | return "*" if helper_has_star(request['If-None-Match']) 115 | 116 | request['If-None-Match'] ? request['If-None-Match'].push(cached['ETag']).uniq.compact.join(', ') : cached['ETag'] 117 | end 118 | 119 | # Use the last modified date if it exists 120 | # source: https://tools.ietf.org/html/rfc7234#section-4.3.2 121 | def helper_timestamp(request, cached) 122 | return request['If-Modified-Since']['httpdate'] if request['If-Modified-Since'] 123 | 124 | cached['Last-Modified']['httpdate'] if cached['Last-Modified'] 125 | end 126 | 127 | # source: https://tools.ietf.org/html/rfc7232#section-2.3 128 | def helper_weak_compare 129 | etag = /^(W\/)?(\"\w+\")$/ 130 | 131 | ->(etag1, etag2) { 132 | # source: https://tools.ietf.org/html/rfc7232#section-2.3.2 133 | opaque_tag1 = etag.match etag1 134 | opaque_tag2 = etag.match etag2 135 | 136 | return false if opaque_tag1.nil? || opaque_tag2.nil? 137 | 138 | opaque_tag1[2] == opaque_tag2[2] 139 | } 140 | end 141 | 142 | # Must use the 'weak comparison' function 143 | # source: https://tools.ietf.org/html/rfc7232#section-3.2 144 | def helper_etag_match(request, cached) 145 | return unless request && cached 146 | 147 | request.any? {|x| 148 | helper_weak_compare.call(x, cached) 149 | } 150 | end 151 | 152 | # It is not possible for a response's ETag to contain a "star", don't check for it 153 | # source: https://tools.ietf.org/html/rfc7232#section-2.3.2 154 | def helper_etag(request, cached) 155 | helper_has_star(request['If-None-Match']) || helper_etag_match(request['If-None-Match'], cached['ETag']) 156 | end 157 | 158 | # source: https://tools.ietf.org/html/rfc7232#section-3.3 159 | def helper_last_modified(request, cached) 160 | rules = { 161 | :response_time => cached['X-Cache-Res-Date']['timestamp'], # Required 162 | :date_value => (cached['Date']['timestamp'] if cached['Date']), 163 | :cached_last_modified => (cached['Last-Modified']['timestamp'] if cached['Last-Modified']), 164 | :if_modified_since => (request['If-Modified-Since']['timestamp'] if request['If-Modified-Since']) 165 | } 166 | return unless rules[:if_modified_since] 167 | 168 | return true if 169 | helper_304_rule1(rules) || 170 | helper_304_rule2(rules) || 171 | helper_304_rule3(rules) || 172 | helper_304_rule4(rules) 173 | 174 | end 175 | 176 | # "A cache recipient SHOULD generate a 304 (Not Modified) response if..." 177 | # source: https://tools.ietf.org/html/rfc7234#section-4.3.2 178 | def helper_304_rule1(rules) 179 | rules[:cached_last_modified] && 180 | rules[:cached_last_modified] <= rules[:if_modified_since] 181 | end 182 | 183 | def helper_304_rule2(rules) 184 | rules[:cached_last_modified].nil? && 185 | rules[:date_value] && 186 | rules[:date_value] <= rules[:if_modified_since] 187 | end 188 | 189 | def helper_304_rule3(rules) 190 | rules[:date_value].nil? && 191 | rules[:cached_last_modified].nil? && 192 | rules[:response_time] <= rules[:if_modified_since] 193 | end 194 | 195 | # "The presented Last-Modified time is at least 60 seconds before the Date value." ¯\_(ツ)_/¯ 196 | # source: https://tools.ietf.org/html/rfc7232#section-2.2.2 197 | def helper_304_rule4(rules) 198 | rules[:if_modified_since] && 199 | rules[:date_value] && 200 | rules[:if_modified_since] <= (rules[:date_value] - 60) 201 | end 202 | 203 | # Don't allow stale if no-cache or no-store headers exist 204 | # source: https://tools.ietf.org/html/rfc7234#section-4.2.4 205 | def helper_validate_allow_stale(request_headers, cached_headers) 206 | return true if (( request = request_headers['Cache-Control'] )) && 207 | ( request['no-cache'] || request['no-store'] ) 208 | 209 | return true if (( cached = cached_headers['Cache-Control'] )) && 210 | ( cached['no-cache'] || 211 | cached['no-store'] || 212 | cached['must-revalidate'] || 213 | cached['s-maxage'] || 214 | cached['proxy-revalidate'] ) 215 | 216 | # Legacy support for HTTP/1.0 Pragma header 217 | # source: https://tools.ietf.org/html/rfc7234#section-5.4 218 | return true if request_headers['Pragma'] == 'no-cache' 219 | end 220 | 221 | def helper_apparent_age(response_time, date_value) 222 | Proc.new { 223 | [0, (response_time - date_value)].max 224 | } 225 | end 226 | 227 | def helper_corrected_age_value(response_time, request_time, age_value) 228 | Proc.new { 229 | # NOTE: It's technically IMPOSSIBLE for response_time to be LOWER THAN request_time 230 | response_delay = response_time - request_time 231 | age_value + response_delay 232 | } 233 | end 234 | 235 | def helper_corrected_initial_age(cached, corrected_age_value, apparent_age) 236 | Proc.new { 237 | if cached['Via'] && cached['Age'] && cached['Via'].none? {|x| x.match /1\.0/ } 238 | # corrected_age_value.call 239 | [0, corrected_age_value.call].max # safeguard just in case 240 | else 241 | [apparent_age.call, corrected_age_value.call].max 242 | end 243 | } 244 | end 245 | 246 | # Calculate the current_age of the cached response 247 | # source: https://tools.ietf.org/html/rfc7234#section-4.2.3 248 | def helper_current_age(now, cached) 249 | date_value = cached['Date']['timestamp'] # Required 250 | request_time = cached['X-Cache-Req-Date']['timestamp'] # Required 251 | response_time = cached['X-Cache-Res-Date']['timestamp'] # Required 252 | age_value = cached['Age'].nil? ? 0 : cached['Age'].to_i 253 | 254 | apparent_age = helper_apparent_age response_time, date_value 255 | corrected_age_value = helper_corrected_age_value response_time, request_time, age_value 256 | corrected_initial_age = helper_corrected_initial_age cached, corrected_age_value, apparent_age 257 | 258 | resident_time = now - response_time 259 | corrected_initial_age.call + resident_time 260 | end 261 | 262 | # Calculate the Freshness Lifetime of the cached response 263 | # source: https://tools.ietf.org/html/rfc7234#section-4.2.1 264 | def helper_freshness_lifetime 265 | now = Time.now.gmtime.to_i 266 | 267 | ->(cached) { 268 | current_age = helper_current_age now, cached 269 | 270 | # source: https://tools.ietf.org/html/rfc7234#section-4.2 271 | freshness_lifetime = helper_explicit(cached) || helper_heuristic(now, cached, current_age) 272 | 273 | [freshness_lifetime, current_age] 274 | } 275 | end 276 | 277 | # If the expire times are explicitly declared 278 | # source: https://tools.ietf.org/html/rfc7234#section-4.2.1 279 | def helper_explicit(cached_headers) 280 | if (( cached = cached_headers['Cache-Control'] )) 281 | return cached['s-maxage']['token'] if cached['s-maxage'] 282 | return cached['max-age']['token'] if cached['max-age'] 283 | end 284 | 285 | return (cached_headers['Expires']['timestamp'] - cached_headers['Date']['timestamp']) if cached_headers['Expires'] 286 | end 287 | 288 | # Calculate Heuristic Freshness if there's no explicit expiration time 289 | # source: https://tools.ietf.org/html/rfc7234#section-4.2.2 290 | def helper_heuristic(now, cached, current_age) 291 | # Use 10% only if there's a Last-Modified header 292 | # source: https://tools.ietf.org/html/rfc7234#section-4.2.2 293 | if cached['Last-Modified'] 294 | result = (now - cached['Last-Modified']['timestamp']) / 10 295 | 296 | # Don't return heuristic responses more than 24 hours old, and avoid sending a 113 Warning ;) 297 | # source: https://tools.ietf.org/html/rfc7234#section-4.2.2 298 | current_age > 86400 ? 0 : result 299 | else 300 | 0 301 | end 302 | end 303 | 304 | # source: https://tools.ietf.org/html/rfc7234#section-5.2.1.2 305 | def helper_max_stale 306 | ->(request, freshness_lifetime, current_age) { 307 | if request && request['max-stale'] 308 | token = request['max-stale']['token'] 309 | token ? (freshness_lifetime.to_i + token.to_i) > current_age : true 310 | else 311 | true 312 | end 313 | } 314 | end 315 | 316 | # source: https://tools.ietf.org/html/rfc7234#section-5.2.1.3 317 | def helper_min_fresh 318 | Proc.new {|request, freshness_lifetime, current_age| 319 | if request && request['min-fresh'] 320 | token = request['min-fresh']['token'] 321 | freshness_lifetime.to_i >= (current_age + token.to_i) 322 | end 323 | } 324 | end 325 | 326 | # source: https://tools.ietf.org/html/rfc7234#section-5.2.2.2 327 | def helper_no_cache 328 | Proc.new {|cached_headers| 329 | nocache = cached_headers['Cache-Control']['no-cache'] 330 | # "If the no-cache response directive specifies one or more field-names..." 331 | (nocache && nocache['quoted_string']) && 332 | nocache['quoted_string'].split(',').map(&:strip).length > 0 333 | } 334 | end 335 | 336 | def helper_make_request(http, request) 337 | Proc.new { http.request request } 338 | end 339 | 340 | def helper_make_request_timer 341 | Proc.new {|url, request, cached| 342 | { 343 | :req_date => Time.now.gmtime.httpdate, 344 | :res => make_request.call(url, request, cached).call, 345 | :res_date => Time.now.gmtime.httpdate 346 | } 347 | } 348 | end 349 | 350 | def helper_response_headers 351 | Proc.new {|result| 352 | res_headers = normalize.(result[:res].to_hash.map &:flatten) 353 | 354 | res_headers['Date'] = result[:res_date] if res_headers['Date'] 355 | res_headers['X-Cache-Req-Date'] = result[:req_date] 356 | res_headers['X-Cache-Res-Date'] = result[:res_date] 357 | res_headers['Status'] = result[:res].code 358 | 359 | res_headers 360 | } 361 | end 362 | 363 | # The validators are required for revalidation 364 | # source: https://tools.ietf.org/html/rfc7232#section-2 365 | def helper_has_preconditions 366 | Proc.new {|request, cached| 367 | request['If-None-Match'] || cached['ETag'] || cached['Last-Modified'] 368 | } 369 | end 370 | end 371 | -------------------------------------------------------------------------------- /lib/validations.rb: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | # 5 | # Copyright (c) 2014-2016 Alexander Williams, Unscramble 6 | 7 | module CacheRules 8 | extend self 9 | 10 | # HTTP Header Validators 11 | # 12 | # Parameters must always be normalized 13 | # 14 | # Return must always be 0 or 1 15 | # 16 | # The If-Match and If-Unmodified-Since conditional header fields are not applicable to a cache. 17 | # source: https://tools.ietf.org/html/rfc7234#section-4.3.2 18 | 19 | def to_bit(&predicate) 20 | predicate.call ? 1 : 0 21 | end 22 | 23 | def validate_cached?(headers) 24 | to_bit { headers[:cached].length > 0 } 25 | end 26 | 27 | # Precedence: If-None-Match (ETag), then If-Modified-Since (Last-Modified) 28 | # source: https://tools.ietf.org/html/rfc7232#section-6 29 | def validate_precond_match?(headers) 30 | request, cached = headers.values_at :request, :cached 31 | return 0 if cached.length == 0 32 | 33 | # Return when the If-None-Match header exists, ignore If-Modified-Since 34 | # source: https://tools.ietf.org/html/rfc7232#section-3.3 35 | etag_match = helper_etag(request, cached) 36 | return to_bit { etag_match } unless etag_match.nil? 37 | 38 | to_bit { helper_last_modified(request, cached) } 39 | end 40 | 41 | # Compare headers to see if the cached request is expired (Freshness) 42 | # source: https://tools.ietf.org/html/rfc7234#section-4.2 43 | def validate_expired?(headers) 44 | return 0 if headers[:cached].length == 0 45 | 46 | freshness_lifetime, current_age = helper_freshness_lifetime.call headers[:cached] 47 | 48 | response_is_fresh = freshness_lifetime.to_i > current_age 49 | 50 | return 1 if headers[:cached]['Cache-Control'] && 51 | headers[:cached]['Cache-Control']['max-age'] && 52 | current_age > headers[:cached]['Cache-Control']['max-age']['token'].to_i 53 | 54 | 55 | to_bit { (response_is_fresh != true) } 56 | end 57 | 58 | def validate_only_if_cached?(headers) 59 | to_bit { headers[:request]['Cache-Control'] && headers[:request]['Cache-Control']['only-if-cached'] } 60 | end 61 | 62 | # Serving Stale Responses 63 | # source: https://tools.ietf.org/html/rfc7234#section-4.2.4 64 | def validate_allow_stale?(headers) 65 | request, cached = headers.values_at :request, :cached 66 | return 0 if cached.length == 0 || helper_validate_allow_stale(request, cached) 67 | 68 | freshness_lifetime, current_age = helper_freshness_lifetime.call cached 69 | 70 | max_stale = helper_max_stale.call request['Cache-Control'], freshness_lifetime, current_age 71 | min_fresh = helper_min_fresh.call request['Cache-Control'], freshness_lifetime, current_age 72 | 73 | to_bit { (max_stale && min_fresh != false) || (max_stale.nil? && min_fresh) } 74 | end 75 | 76 | # Response Cache-Control Directives 77 | # source: https://tools.ietf.org/html/rfc7234#section-5.2.2 78 | def validate_must_revalidate?(headers) 79 | return 1 if headers[:cached].length == 0 80 | 81 | # source: https://tools.ietf.org/html/rfc7234#section-5.2.2.1 82 | # source: https://tools.ietf.org/html/rfc7234#section-5.2.2.7 83 | to_bit { (( cached = headers[:cached]['Cache-Control'] )) && ( cached['must-revalidate'] || cached['proxy-revalidate'] ) } 84 | end 85 | 86 | # Verify if we're explicitly told not to serve a response without revalidation 87 | def validate_no_cache?(headers) 88 | request_headers, cached_headers = headers.values_at :request, :cached 89 | return 1 if cached_headers.length == 0 90 | 91 | # Must revalidate if this request header exists 92 | # source: https://tools.ietf.org/html/rfc7234#section-5.2.1.4 93 | return 1 if (request_headers['Cache-Control'] && request_headers['Cache-Control']['no-cache']) 94 | 95 | _, current_age = helper_freshness_lifetime.call cached_headers 96 | 97 | # If max-age is 0 or if the current age is above the max-age 98 | # source: https://tools.ietf.org/html/rfc7234#section-5.2.1.1 99 | return 1 if request_headers['Cache-Control'] && 100 | request_headers['Cache-Control']['max-age'] && 101 | (request_headers['Cache-Control']['max-age']['token'].to_s == "0" || current_age > request_headers['Cache-Control']['max-age']['token'].to_i) 102 | 103 | # source: https://tools.ietf.org/html/rfc7234#section-5.2.2.2 104 | # source: https://tools.ietf.org/html/rfc7234#section-3.2 105 | if cached_headers['Cache-Control'] 106 | return 1 if (( cached = cached_headers['Cache-Control'] )) && 107 | helper_no_cache.call(cached_headers) || 108 | (cached['no-cache'] && cached['no-cache']['quoted_string'].nil?) || 109 | (cached['s-maxage'] && cached['s-maxage']['token'].to_s == "0") || 110 | (cached['max-age'] && cached['max-age']['token'].to_s == "0") 111 | end 112 | 113 | # source: https://tools.ietf.org/html/rfc7234#section-5.4 114 | # Legacy support for HTTP/1.0 Pragma header 115 | return 1 if request_headers['Pragma'] && request_headers['Pragma']['no-cache'] 116 | 117 | return 0 118 | end 119 | 120 | def validate_is_error?(headers) 121 | to_bit { headers[:response]['Status'].to_i.between?(500,599) } 122 | end 123 | 124 | def validate_validator_match?(headers) 125 | request, response = headers.values_at :request, :response 126 | to_bit { response['ETag'] && request['If-None-Match'] && (request['If-None-Match'].include?(response['ETag']) || request['If-None-Match'].include?("*")) } 127 | end 128 | 129 | end 130 | -------------------------------------------------------------------------------- /test/helper.rb: -------------------------------------------------------------------------------- 1 | require 'coveralls' 2 | require 'fakeweb' 3 | require 'simplecov' 4 | 5 | Coveralls.wear! 6 | 7 | SimpleCov.start do 8 | add_filter '/test/' 9 | add_filter '.bundle' 10 | minimum_coverage 100 11 | end 12 | 13 | require './lib/cache_rules' 14 | require 'minitest/autorun' 15 | require 'minitest/unit' 16 | require 'minitest/reporters' 17 | 18 | Minitest::Reporters.use! [Minitest::Reporters::SpecReporter.new] 19 | -------------------------------------------------------------------------------- /test/test_cache_rules.rb: -------------------------------------------------------------------------------- 1 | class TestCacheRules < MiniTest::Test 2 | 3 | def setup 4 | @request_if_none_match = CacheRules.normalize.call({ "Host" => "test.com", "If-None-Match" => "*", "Cache-Control" => "max-stale=100000000" }) 5 | @request_if_modified_since_yes = CacheRules.normalize.call({ "Host" => "test.com", "If-Modified-Since" => "Thu, 01 Jan 2015 07:03:45 GMT", "Cache-Control" => "max-stale=100000000" }) 6 | @request_if_modified_since_no = CacheRules.normalize.call({ "Host" => "test.com" }) 7 | @cached_headers = { 8 | "Date" => {"httpdate"=>"Thu, 01 Jan 2015 07:03:45 GMT", "timestamp"=>1420095825}, 9 | "Cache-Control" => { 10 | "public" => {"token"=>nil, "quoted_string"=>nil} 11 | }, 12 | "X-Cache-Req-Date" => {"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, 13 | "X-Cache-Res-Date" => {"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625} 14 | } 15 | end 16 | 17 | def test_get_money 18 | got_money = CacheRules::Everything::Around::Me.get_the_money 19 | 20 | assert_equal got_money, "Dolla Dolla Bill Y'all" 21 | end 22 | 23 | def test_validate_column0_query_params_fail 24 | request = {"Host"=>"test.url"} 25 | 26 | result = CacheRules.validate('http://test.url/test1?test=string', request) 27 | 28 | assert_equal result[:code], 307 29 | assert_nil result[:body] 30 | assert_equal result[:headers]['Cache-Lookup'], 'MISS' 31 | refute_equal result[:headers]['Location'], "http://test.url/test1" 32 | end 33 | 34 | def test_validate_column0 35 | request = {"Host"=>"test.url"} 36 | 37 | result = CacheRules.validate('http://test.url/test1?test=string', request) 38 | 39 | assert_equal result[:code], 307 40 | assert_nil result[:body] 41 | assert_equal result[:headers]['Cache-Lookup'], 'MISS' 42 | assert_equal result[:headers]['Location'], "http://test.url/test1?test=string" 43 | end 44 | 45 | def test_validate_column1 46 | request = {"Host"=>"test.url","Cache-Control"=>"only-if-cached"} 47 | 48 | result = CacheRules.validate('http://test.url/test1', request) 49 | 50 | assert_equal result[:code], 504 51 | assert_equal result[:body], 'Gateway Timeout' 52 | assert_equal result[:headers]['Cache-Lookup'], 'MISS' 53 | end 54 | 55 | def test_validate_column2 56 | request = {"Host"=>"test.url"} 57 | cached = {"Date"=>{"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "Cache-Control"=>{"s-maxage"=>{"token"=>"100000000", "quoted_string"=>nil}}, "X-Cache-Req-Date"=>{"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "X-Cache-Res-Date"=>{"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "Last-Modified" => {"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}} 58 | 59 | result = CacheRules.validate('http://test.url/test1', request, cached) 60 | 61 | assert_equal result[:code], 200 62 | assert_equal result[:body], 'cached' 63 | assert_equal result[:headers]['Cache-Lookup'], 'HIT' 64 | end 65 | 66 | def test_validate_column3 67 | request = {"Host"=>"test.url", "If-None-Match" => "*"} 68 | cached = {"Date"=>{"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "Cache-Control"=>{"s-maxage"=>{"token"=>"100000000", "quoted_string"=>nil}}, "X-Cache-Req-Date"=>{"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "X-Cache-Res-Date"=>{"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "Last-Modified" => {"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "ETag" => "\"validEtag\""} 69 | 70 | result = CacheRules.validate('http://test.url/test1', request, cached) 71 | 72 | assert_equal result[:code], 304 73 | assert_nil result[:body] 74 | assert_equal result[:headers]['Cache-Lookup'], 'HIT' 75 | end 76 | 77 | def test_validate_column4 78 | request = {"Host"=>"test.url"} 79 | cached = {"Date"=>{"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "X-Cache-Req-Date"=>{"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "X-Cache-Res-Date"=>{"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "Last-Modified" => {"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "Last-Modified" => {"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "ETag" => "\"validEtag\"", "Cache-Control"=>{"max-age"=>{"token"=>"100", "quoted_string" => nil}}} 80 | 81 | result = CacheRules.validate('http://test.url/test1', request, cached) 82 | 83 | assert_equal 200, result[:code] 84 | assert_equal result[:body], 'stale' 85 | assert_equal result[:headers]['Cache-Lookup'], 'STALE' 86 | assert_equal result[:headers]['Warning'], "110 - \"Response is Stale\"" 87 | end 88 | 89 | def test_validate_column5 90 | request = {"Host"=>"test.url", "If-None-Match"=>"*"} 91 | cached = {"Date"=>{"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "X-Cache-Req-Date"=>{"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "X-Cache-Res-Date"=>{"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "Last-Modified" => {"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "Last-Modified" => {"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "ETag" => "\"validEtag\"", "Cache-Control"=>{"max-age"=>{"token"=>"100", "quoted_string" => nil}}} 92 | 93 | result = CacheRules.validate('http://test.url/test1', request, cached) 94 | 95 | assert_equal result[:code], 304 96 | assert_nil result[:body] 97 | assert_equal result[:headers]['Cache-Lookup'], 'STALE' 98 | assert_equal result[:headers]['Warning'], "110 - \"Response is Stale\"" 99 | end 100 | 101 | def test_validate_column6 102 | request = {"Host"=>"test.url", "Cache-Control"=>"max-stale=0", "If-None-Match"=>"*"} 103 | cached = {"Date"=>{"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "X-Cache-Req-Date"=>{"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "X-Cache-Res-Date"=>{"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "Last-Modified" => {"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "Last-Modified" => {"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "ETag" => "\"validEtag\"", "Cache-Control"=>{"max-age"=>{"token"=>"100", "quoted_string" => nil}}} 104 | 105 | result = CacheRules.validate('http://test.url/test1', request, cached) 106 | 107 | assert_equal result[:code], 504 108 | assert_equal result[:body], 'Gateway Timeout' 109 | assert_equal result[:headers]['Cache-Lookup'], 'EXPIRED' 110 | end 111 | 112 | def test_validate_column7 113 | request = {"Host"=>"test.url", "Cache-Control"=>"max-stale=0", "If-None-Match"=>["*"]} 114 | cached = {"Date"=>{"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "X-Cache-Req-Date"=>{"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "X-Cache-Res-Date"=>{"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "Last-Modified" => {"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "Last-Modified" => {"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "ETag" => "\"validEtag\"", "Cache-Control"=>{"max-age"=>{"token"=>"100", "quoted_string" => nil}, "must-revalidate"=>{"token"=>nil, "quoted_string"=>nil}}} 115 | 116 | FakeWeb.register_uri(:head, "http://test.url/test1", :status => ["304", "Not Modified"], :date => "Sat, 03 Jan 2015 07:15:45 GMT") 117 | result = CacheRules.validate('http://test.url/test1', request, cached) 118 | 119 | assert_equal result[:code], 200 120 | assert_equal result[:body], "cached" 121 | assert_equal result[:headers]['Cache-Lookup'], 'REVALIDATED' 122 | end 123 | 124 | def test_validate_column8 125 | request = {"Host"=>"test.url", "Cache-Control"=>"max-stale=0", "If-None-Match"=>["*"]} 126 | cached = {"Date"=>{"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "X-Cache-Req-Date"=>{"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "X-Cache-Res-Date"=>{"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "Last-Modified" => {"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "Last-Modified" => {"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "ETag" => "\"validEtag\"", "Cache-Control"=>{"max-age"=>{"token"=>"100", "quoted_string" => nil}, "no-cache"=>{"token"=>nil, "quoted_string"=>nil}}} 127 | 128 | FakeWeb.register_uri(:head, "http://test.url/test2", :status => ["304", "Not Modified"], :date => "Sat, 03 Jan 2015 07:15:45 GMT") 129 | result = CacheRules.validate('http://test.url/test2', request, cached) 130 | 131 | assert_equal 200, result[:code] 132 | assert_equal result[:body], "cached" 133 | assert_equal result[:headers]['Cache-Lookup'], 'REVALIDATED' 134 | end 135 | 136 | def test_revalidate_response_column0 137 | request = {"Host"=>"test.url", "If-None-Match"=>["*"], "Cache-Control"=>{"max-age"=>{"token"=>"100000000", "quoted_string"=>nil}}} 138 | cached = {"Date"=>{"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "Cache-Control"=>{"s-maxage"=>{"token"=>"100000000", "quoted_string"=>nil}}, "X-Cache-Req-Date"=>{"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "X-Cache-Res-Date"=>{"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "Last-Modified" => {"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}} 139 | 140 | FakeWeb.register_uri(:head, "http://test.url/test1?test=string", :status => ["304", "Not Modified"], :date => "Sat, 03 Jan 2015 07:15:45 GMT", :Warning => "299 - \"Hello World\"") 141 | result = CacheRules.revalidate_response('http://test.url/test1?test=string', request, cached) 142 | 143 | assert_equal result[:code], 200 144 | assert_equal result[:body], 'cached' 145 | assert_equal 'REVALIDATED', result[:headers]['Cache-Lookup'] 146 | assert_equal result[:headers]['Warning'], "299 - \"Hello World\"" 147 | end 148 | 149 | def test_revalidate_response_column1 150 | request = {"Host"=>"test.url", "If-None-Match"=>["\"validEtag\""], "Cache-Control"=>{"max-age"=>{"token"=>"100000000", "quoted_string"=>nil}}} 151 | cached = {"Date"=>{"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "Cache-Control"=>{"s-maxage"=>{"token"=>"100000000", "quoted_string"=>nil}}, "X-Cache-Req-Date"=>{"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "X-Cache-Res-Date"=>{"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "Last-Modified" => {"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "ETag" => "\"validEtag\"", "Content-Type" => "text/html"} 152 | 153 | FakeWeb.register_uri(:head, "http://test.url/test1", :status => ["304", "Not Modified"], :date => "Sat, 03 Jan 2015 07:15:45 GMT", :ETag => "\"validEtag\"", :Warning => "299 - \"Hello World\"") 154 | result = CacheRules.revalidate_response('http://test.url/test1', request, cached) 155 | 156 | assert_equal result[:code], 304 157 | assert_nil result[:body] 158 | assert_equal result[:headers]['Cache-Lookup'], 'REVALIDATED' 159 | assert_equal result[:headers]['ETag'], "\"validEtag\"" 160 | assert_equal result[:headers]['Warning'], "299 - \"Hello World\"" 161 | assert_equal result[:headers]['Content-Type'], "text/html" 162 | end 163 | 164 | def test_revalidate_response_column2_5xx 165 | request = {"Host"=>"test.url", "If-None-Match"=>["\"validEtag\""], "Cache-Control"=>{"max-age"=>{"token"=>"100000000", "quoted_string"=>nil}}} 166 | cached = {"Date"=>{"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "Cache-Control"=>{"s-maxage"=>{"token"=>"100000000", "quoted_string"=>nil}}, "X-Cache-Req-Date"=>{"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "X-Cache-Res-Date"=>{"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "Last-Modified" => {"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "ETag" => "\"validEtag\""} 167 | 168 | FakeWeb.register_uri(:head, "http://test.url/test1", :status => ["504", "Gateway Timeout"], :date => "Sat, 03 Jan 2015 07:15:45 GMT", :ETag => "\"validEtag\"") 169 | result = CacheRules.revalidate_response('http://test.url/test1', request, cached) 170 | 171 | assert_equal result[:code], 504 172 | assert_equal result[:body], 'Gateway Timeout' 173 | assert_equal result[:headers]['Cache-Lookup'], 'EXPIRED' 174 | end 175 | 176 | def test_revalidate_response_column2_error 177 | result = CacheRules.revalidate_response('ftp://test.url/test1', {}, {'Last-Modified'=>'Sat, 03 Jan 2015 07:03:45 GMT'}) 178 | 179 | assert_equal result[:code], 504 180 | assert_equal result[:body], 'Gateway Timeout' 181 | assert result[:error] 182 | end 183 | 184 | def test_revalidate_response_column3 185 | request = {"Host"=>"test.url", "If-None-Match"=>["*"], "Cache-Control"=>{"max-stale"=>{"token"=>"100000000", "quoted_string"=>nil}}} 186 | cached = {"Date"=>{"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "X-Cache-Req-Date"=>{"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "X-Cache-Res-Date"=>{"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "Last-Modified" => {"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "ETag" => "\"validEtag\""} 187 | 188 | FakeWeb.register_uri(:head, "http://test.url/test1", :status => ["504", "Gateway Timeout"], :date => "Sat, 03 Jan 2015 07:15:45 GMT") 189 | result = CacheRules.revalidate_response('http://test.url/test1', request, cached) 190 | 191 | assert_equal result[:code], 200 192 | assert_equal result[:body], 'stale' 193 | assert_equal result[:headers]['Warning'], "111 - \"Revalidation Failed\"" 194 | assert_equal result[:headers]['Cache-Lookup'], 'STALE' 195 | end 196 | 197 | def test_revalidate_response_column4 198 | request = {"Host"=>"test.url", "If-None-Match"=>["\"validEtag\""], "Cache-Control"=>{"max-stale"=>{"token"=>"100000000", "quoted_string"=>nil}}} 199 | cached = {"Date"=>{"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "X-Cache-Req-Date"=>{"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "X-Cache-Res-Date"=>{"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "Last-Modified" => {"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "ETag" => "\"validEtag\""} 200 | 201 | FakeWeb.register_uri(:head, "http://test.url/test1", :status => ["504", "Gateway Timeout"], :date => "Sat, 03 Jan 2015 07:15:45 GMT", :ETag => "\"validEtag\"") 202 | result = CacheRules.revalidate_response('http://test.url/test1', request, cached) 203 | 204 | assert_equal result[:code], 304 205 | assert_nil result[:body] 206 | assert_equal result[:headers]['Warning'], "111 - \"Revalidation Failed\"" 207 | assert_equal result[:headers]['Cache-Lookup'], 'STALE' 208 | assert_equal result[:headers]['ETag'], "\"validEtag\"" 209 | end 210 | 211 | def test_make_http_request_with_entity_tag 212 | result = CacheRules.make_request.call('http://test.url', @request_if_none_match, @cached_headers) 213 | http = eval "http", result.binding 214 | request = eval "request", result.binding 215 | 216 | assert_kind_of Proc, result 217 | assert_kind_of FalseClass, http.use_ssl? 218 | assert_equal http.address, 'test.url' 219 | assert_equal http.port, 80 220 | assert_equal request.method, 'HEAD' 221 | end 222 | 223 | def test_make_http_request_with_last_modified 224 | result = CacheRules.make_request.call('http://test.url', @request_if_modified_since_yes, @cached_headers) 225 | http = eval "http", result.binding 226 | request = eval "request", result.binding 227 | 228 | assert_kind_of Proc, result 229 | assert_kind_of FalseClass, http.use_ssl? 230 | assert_equal http.address, 'test.url' 231 | assert_equal http.port, 80 232 | assert_equal request.method, 'HEAD' 233 | end 234 | 235 | def test_make_http_request_without_preconditions 236 | result = CacheRules.make_request.call('https://test.url', @request_if_modified_since_no, @cached_headers) 237 | http = eval "http", result.binding 238 | request = eval "request", result.binding 239 | 240 | assert_kind_of Proc, result 241 | assert_kind_of TrueClass, http.use_ssl? 242 | assert_equal http.address, 'test.url' 243 | assert_equal http.port, 443 244 | assert_equal request.method, 'HEAD' 245 | end 246 | 247 | def test_raises_an_error_if_the_url_is_invalid 248 | assert_raises(SocketError) { CacheRules.make_request.call('http://test.urlzzz', {}, {}).call } 249 | assert_raises(NoMethodError) { CacheRules.make_request.call('ftp://test.urlzzz', {}, {}).call } 250 | end 251 | 252 | end 253 | -------------------------------------------------------------------------------- /test/test_formatting.rb: -------------------------------------------------------------------------------- 1 | class TestFormatting < MiniTest::Test 2 | 3 | def setup 4 | @request_headers = { 5 | "Version" => "HTTP/1.1", 6 | "user-agent" => "test user agent", 7 | "Cookie" => "testcookie=deleteme", 8 | "If-Modified-Since" => "Thu, 01 Jan 2015 07:03:42 GMT", 9 | "Cache-Control" => "max-stale=1000, no-cache=\"Cookie\", no-store", 10 | "Referer" => "http://some.url" 11 | } 12 | @cached_headers = { 13 | "Retry-After" => 60, 14 | "Cache-Control" => { 15 | "public" => {"token"=>nil, "quoted_string"=>nil}, 16 | "max-stale" => {"token"=>"1000", "quoted_string"=>nil}, 17 | "no-cache" => {"token"=>nil, "quoted_string"=>"Cookie"} 18 | }, 19 | "Last-Modified" => {"httpdate"=>"Thu, 01 Jan 2015 07:03:42 GMT", "timestamp"=>1420095822}, 20 | "ETag" => "\"validEtag\"" 21 | } 22 | @nocache_headers = {"Version"=>"HTTP/1.1", "Set-Cookie"=>"test", "Cookie"=>"test", "Accept-Ranges"=>"test", "Range"=>"test", "If-Range"=>"test", "Content-Range"=>"test", "Referer"=>"http://test.url", "From"=>"test", "Authorization"=>"test", "Proxy-Authorization"=>"test", "User-Agent"=>"test", "If-Modified-Since"=>"invalid date"} 23 | @duplicate_headers = [ 24 | ['Accept', 'text/html'], 25 | ['Accept', 'application/json'] 26 | ] 27 | @non_duplicate_headers = [ 28 | ['Age', '12345'], 29 | ['Age', '67890'] 30 | ] 31 | @normalized = {"Version"=>"HTTP/1.1", "User-Agent"=>"test user agent", "If-Modified-Since"=>{"httpdate"=>"Thu, 01 Jan 2015 07:03:42 GMT", "timestamp"=>1420095822}, "Cache-Control"=>{"max-stale"=>{"token"=>"1000", "quoted_string"=>nil}, "no-cache"=>{"token"=>nil, "quoted_string"=>"Cookie"}, "no-store"=>{"token"=>nil, "quoted_string"=>nil}}} 32 | @normalized_fields = [["Version", "HTTP/1.1"], ["user-agent", "test user agent"], ["Cookie", "testcookie=deleteme"], ["If-Modified-Since", {"httpdate"=>"Thu, 01 Jan 2015 07:03:42 GMT", "timestamp"=>1420095822}], ["Cache-Control", {"max-stale"=>{"token"=>"1000", "quoted_string"=>nil}, "no-cache"=>{"token"=>nil, "quoted_string"=>"Cookie"}, "no-store"=>{"token"=>nil, "quoted_string"=>nil}}], ["Referer", "http://some.url"]] 33 | @unnormalized_fields = {"Retry-After"=>"60", "Cache-Control"=>"public, max-stale=1000, no-cache=\"Cookie\"", "Last-Modified"=>"Thu, 01 Jan 2015 07:03:42 GMT", "ETag"=>"\"validEtag\""} 34 | end 35 | 36 | def test_normalize 37 | normalized = CacheRules.normalize.call @request_headers 38 | 39 | assert_kind_of Hash, normalized 40 | 41 | assert_includes normalized, 'Version' 42 | assert_includes normalized, 'User-Agent' 43 | assert_includes normalized, 'If-Modified-Since' 44 | assert_includes normalized, 'Cache-Control' 45 | 46 | assert_equal normalized, @normalized 47 | end 48 | 49 | def test_format_key 50 | etag = CacheRules.format_key.call 'etag', 'test value' 51 | user = CacheRules.format_key.call 'user-agent', 'test user agent' 52 | vary = CacheRules.format_key.call 'Vary', '*' 53 | 54 | assert_equal etag, ['ETag', 'test value'] 55 | assert_equal user, ['User-Agent', 'test user agent'] 56 | assert_equal vary, ['Vary', '*'] 57 | end 58 | 59 | def test_clean 60 | cleaned = CacheRules.clean.call @nocache_headers 61 | # if_modified = CacheRules.clean.call @nocache_headers 62 | assert_equal cleaned, [['Version', 'HTTP/1.1'], ['User-Agent', 'test']] 63 | end 64 | 65 | def test_combine 66 | combined = CacheRules.combine.call @duplicate_headers 67 | not_combined = CacheRules.combine.call @non_duplicate_headers 68 | 69 | assert_equal combined, [['Accept', 'text/html, application/json']] 70 | assert_equal not_combined, [['Age', '12345']] 71 | end 72 | 73 | def test_normalize_fields 74 | normalized = CacheRules.normalize_fields.call @request_headers 75 | 76 | assert_kind_of Array, normalized 77 | assert_equal normalized, @normalized_fields 78 | end 79 | 80 | def test_unnormalize_fields 81 | unnormalized = CacheRules.unnormalize_fields.call @cached_headers 82 | 83 | assert_kind_of Hash, unnormalized 84 | assert_equal unnormalized, @unnormalized_fields 85 | end 86 | 87 | def test_format_field 88 | httpdate = CacheRules.format_field.call 'If-Modified-Since', @request_headers['If-Modified-Since'] 89 | cache_control = CacheRules.format_field.call 'Cache-Control', @request_headers['Cache-Control'] 90 | csv = CacheRules.format_field.call 'Accept', 'text/html, application/json' 91 | retry_after = CacheRules.format_field.call 'Retry-After', 60 92 | retry_after_abs = CacheRules.format_field.call 'Retry-After', -100 93 | retry_after_date= CacheRules.format_field.call 'Retry-After', 'Thu, 01 Jan 2015 07:03:42 GMT', false 94 | other = CacheRules.format_field.call 'Version', @request_headers['Version'], false 95 | 96 | cur_time = Time.now.gmtime.to_i 97 | baddate = CacheRules.format_field.call 'If-Modified-Since', 'invalid date' 98 | 99 | assert_kind_of Array, baddate 100 | 101 | assert_equal httpdate, ["If-Modified-Since", {"httpdate"=>"Thu, 01 Jan 2015 07:03:42 GMT", "timestamp"=>1420095822}] 102 | assert_equal cache_control, ["Cache-Control", {"max-stale"=>{"token"=>"1000", "quoted_string"=>nil}, "no-cache"=>{"token"=>nil, "quoted_string"=>"Cookie"}, "no-store"=>{"token"=>nil, "quoted_string"=>nil}}] 103 | assert_equal csv, ["Accept", ["text/html", "application/json"]] 104 | assert_equal retry_after, ["Retry-After", 60] 105 | assert_equal retry_after_abs, ["Retry-After", 100] 106 | assert_equal retry_after_date, ["Retry-After", {"httpdate"=>"Thu, 01 Jan 2015 07:03:42 GMT", "timestamp"=>1420095822}] 107 | assert_equal other, ["Version", "HTTP/1.1"] 108 | 109 | assert_in_delta baddate[1]["timestamp"], (cur_time - 300), 2, "Check the httpdate_helper, ensure it's 300 seconds, or increase the delta" 110 | end 111 | 112 | def test_format_field_string 113 | cache_control = CacheRules.format_field.call 'Cache-Control', @cached_headers['Cache-Control'], true 114 | csv = CacheRules.format_field.call 'Accept', ["text/html", "application/json"], true 115 | 116 | assert_equal cache_control, ["Cache-Control", "public, max-stale=1000, no-cache=\"Cookie\""] 117 | assert_equal csv, ["Accept", "text/html, application/json"] 118 | end 119 | 120 | def test_httpdate_helper 121 | httpdate = "Sun, 06 Nov 1994 08:49:37 GMT" 122 | rfc850 = "Sunday, 06-Nov-94 08:49:37 GMT" 123 | ansi_c = "Sun Nov 6 08:49:37 1994" 124 | result = 784111777 125 | 126 | timestamp1 = CacheRules.httpdate_helper httpdate 127 | timestamp2 = CacheRules.httpdate_helper rfc850 128 | timestamp3 = CacheRules.httpdate_helper ansi_c 129 | timestamp4 = CacheRules.httpdate_helper "2012-13-08T15:43:59" # invalid date 130 | 131 | assert_equal timestamp1, result 132 | assert_equal timestamp2, result 133 | assert_equal timestamp3, result 134 | assert timestamp4 135 | end 136 | 137 | end 138 | -------------------------------------------------------------------------------- /test/test_helpers.rb: -------------------------------------------------------------------------------- 1 | class TestHelpers < MiniTest::Test 2 | 3 | def setup 4 | cache_control = {"max-stale"=>{"token"=>"1000", "quoted_string"=>nil}, "no-cache"=>{"token"=>nil, "quoted_string"=>nil}} 5 | if_modified = {"httpdate"=>"Thu, 01 Jan 2015 07:03:42 GMT", "timestamp"=>1420095822} 6 | date = {"httpdate"=>"Fri, 02 Jan 2015 11:03:45 GMT", "timestamp"=>1420196625} 7 | date_minus_60 = {"httpdate"=>"Fri, 02 Jan 2015 11:02:45 GMT", "timestamp"=>1420196565} 8 | next_date = {"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625} 9 | if_modified_new = {"httpdate"=>"Sun, 04 Jan 2015 09:03:45 GMT", "timestamp"=>1420362225} 10 | 11 | @request_headers = { 12 | "If-Modified-Since" => if_modified, 13 | "Cache-Control" => cache_control, 14 | "If-None-Match" => ["*"] 15 | } 16 | @request_headers_new = { 17 | "If-Modified-Since" => if_modified_new, 18 | "Cache-Control" => cache_control, 19 | "If-None-Match" => ["*"] 20 | } 21 | @request_headers_60 = { 22 | "If-Modified-Since" => date_minus_60, 23 | "Cache-Control" => cache_control, 24 | "If-None-Match" => ["*"] 25 | } 26 | @request_headers_combine = { 27 | "If-Modified-Since" => if_modified, 28 | "Cache-Control" => cache_control, 29 | "If-None-Match" => ["\"myetag\"", "\"validEtag\""] 30 | } 31 | @request_headers_combine_nothing = { 32 | "Cache-Control" => cache_control 33 | } 34 | @request_headers_nothing = { 35 | "If-None-Match" => ["\"myetag\""] 36 | } 37 | @cached_headers = { 38 | "Date" => date, 39 | "Cache-Control" => { 40 | "public" => {"token"=>nil, "quoted_string"=>nil}, 41 | "max-stale" => {"token"=>"1000", "quoted_string"=>nil}, 42 | "no-cache" => {"token"=>nil, "quoted_string"=>"Cookie"} 43 | }, 44 | "Last-Modified" => date, 45 | "X-Cache-Req-Date" => next_date, 46 | "X-Cache-Res-Date" => next_date, 47 | "ETag" => "\"validEtag\"" 48 | } 49 | @cached_rule2 = { 50 | "Date" => date, 51 | "X-Cache-Res-Date" => next_date 52 | } 53 | @cached_rule3 = { 54 | "X-Cache-Res-Date" => next_date 55 | } 56 | end 57 | 58 | def test_run_validate 59 | is_proc = CacheRules.helper_run_validate 60 | result = is_proc.call CacheRules::REQUEST_TABLE[:conditions], @request_headers, @cached_headers, nil 61 | 62 | assert_kind_of Proc, is_proc 63 | assert_kind_of Array, result 64 | assert_equal result, [1, 0, 1, 1, 1, 0, 0] 65 | end 66 | 67 | def test_response_request 68 | url = 'http://test.url?test=string' 69 | act = CacheRules::REQUEST_TABLE[:actions] 70 | 71 | column0 = CacheRules.helper_response url, act, 0, @cached_headers 72 | column1 = CacheRules.helper_response url, act, 1, @cached_headers 73 | column2 = CacheRules.helper_response url, act, 2, @cached_headers 74 | column3 = CacheRules.helper_response url, act, 3, @cached_headers 75 | column4 = CacheRules.helper_response url, act, 4, @cached_headers 76 | column5 = CacheRules.helper_response url, act, 5, @cached_headers 77 | column6 = CacheRules.helper_response url, act, 6, @cached_headers 78 | column7 = CacheRules.helper_response url, act, 7, @cached_headers 79 | column8 = CacheRules.helper_response url, act, 8, @cached_headers 80 | 81 | assert_equal column0, {:body=>nil, :code=>307, :headers=>{"Cache-Lookup"=>"MISS", "Location"=>"http://test.url?test=string"}} 82 | assert_equal column1, {:body=>"Gateway Timeout", :code=>504, :headers=>{"Cache-Lookup"=>"MISS"}} 83 | 84 | assert_equal column2[:body], 'cached' 85 | assert_equal column2[:code], 200 86 | assert_equal column2[:headers]['Cache-Lookup'], 'HIT' 87 | assert_includes column2[:headers], 'Age' 88 | 89 | assert_nil column3[:body] 90 | assert_equal column3[:code], 304 91 | assert_equal column3[:headers]['Cache-Lookup'], 'HIT' 92 | assert_equal column3[:headers]['Date'], "Fri, 02 Jan 2015 11:03:45 GMT" 93 | assert_equal column3[:headers]['Cache-Control'], "public, max-stale=1000, no-cache=\"Cookie\"" 94 | assert_equal column3[:headers]['ETag'], "\"validEtag\"" 95 | assert_includes column3[:headers], 'Age' 96 | 97 | assert_equal column4[:body], 'stale' 98 | assert_equal column4[:code], 200 99 | assert_equal column4[:headers]['Cache-Lookup'], 'STALE' 100 | assert_equal column4[:headers]['Warning'], "110 - \"Response is Stale\"" 101 | assert_includes column4[:headers], 'Age' 102 | 103 | assert_nil column5[:body] 104 | assert_equal column5[:code], 304 105 | assert_equal column5[:headers]['Cache-Lookup'], 'STALE' 106 | assert_equal column5[:headers]['Warning'], "110 - \"Response is Stale\"" 107 | assert_equal column5[:headers]['Date'], "Fri, 02 Jan 2015 11:03:45 GMT" 108 | assert_equal column5[:headers]['Cache-Control'], "public, max-stale=1000, no-cache=\"Cookie\"" 109 | assert_equal column5[:headers]['ETag'], "\"validEtag\"" 110 | assert_includes column5[:headers], 'Age' 111 | 112 | assert_equal column6, {:body=>"Gateway Timeout", :code=>504, :headers=>{"Cache-Lookup"=>"EXPIRED"}} 113 | assert_equal column7, {:body=>nil, :code=>nil, :headers=>nil} 114 | assert_equal column8, {:body=>nil, :code=>nil, :headers=>nil} 115 | 116 | assert_kind_of String, column2[:headers]['Age'] 117 | end 118 | 119 | def test_response_revalidate 120 | url = 'http://test.url' 121 | act = CacheRules::RESPONSE_TABLE[:actions] 122 | 123 | column0 = CacheRules.helper_response url, act, 0, @cached_headers 124 | column1 = CacheRules.helper_response url, act, 1, @cached_headers 125 | column2 = CacheRules.helper_response url, act, 2, @cached_headers 126 | column3 = CacheRules.helper_response url, act, 3, @cached_headers 127 | column4 = CacheRules.helper_response url, act, 4, @cached_headers 128 | 129 | assert_equal column0[:body], 'cached' 130 | assert_equal column0[:code], 200 131 | assert_equal column0[:headers]['Cache-Lookup'], 'REVALIDATED' 132 | assert_includes column0[:headers], 'Age' 133 | 134 | assert_nil column1[:body] 135 | assert_equal column1[:code], 304 136 | assert_equal column1[:headers]['Cache-Lookup'], 'REVALIDATED' 137 | assert_equal column1[:headers]['Date'], "Fri, 02 Jan 2015 11:03:45 GMT" 138 | assert_equal column1[:headers]['Cache-Control'], "public, max-stale=1000, no-cache=\"Cookie\"" 139 | assert_equal column1[:headers]['ETag'], "\"validEtag\"" 140 | assert_includes column1[:headers], 'Age' 141 | 142 | assert_equal column2, {:body=>"Gateway Timeout", :code=>504, :headers=>{"Cache-Lookup"=>"EXPIRED"}} 143 | 144 | assert_equal column3[:body], 'stale' 145 | assert_equal column3[:code], 200 146 | assert_equal column3[:headers]['Cache-Lookup'], 'STALE' 147 | assert_equal column3[:headers]['Warning'], "111 - \"Revalidation Failed\"" 148 | assert_includes column3[:headers], 'Age' 149 | 150 | assert_nil column4[:body] 151 | assert_equal column4[:code], 304 152 | assert_equal column4[:headers]['Cache-Lookup'], 'STALE' 153 | assert_equal column4[:headers]['Warning'], "111 - \"Revalidation Failed\"" 154 | assert_equal column4[:headers]['Date'], "Fri, 02 Jan 2015 11:03:45 GMT" 155 | assert_equal column4[:headers]['Cache-Control'], "public, max-stale=1000, no-cache=\"Cookie\"" 156 | assert_equal column4[:headers]['ETag'], "\"validEtag\"" 157 | assert_includes column4[:headers], 'Age' 158 | 159 | assert_kind_of String, column0[:headers]['Age'] 160 | end 161 | 162 | def test_is_if_modified_error 163 | not_error = CacheRules.helper_is_if_modified_error? 'If-Modified-Since', "Fri, 02 Jan 2015 11:03:45 GMT" 164 | is_error = CacheRules.helper_is_if_modified_error? 'If-Modified-Since', "invalid date" 165 | noop = CacheRules.helper_is_if_modified_error? 'Not-Even-A-Key', "test" 166 | 167 | assert_kind_of FalseClass, not_error 168 | assert_kind_of TrueClass, is_error 169 | assert_nil noop 170 | end 171 | 172 | def test_headers_200_304 173 | headers = CacheRules.helper_headers_200_304.call(@cached_headers, {'Warning' => ['199 - Hello World']}) 174 | 175 | assert_kind_of Hash, headers 176 | 177 | assert_equal headers, {"Date"=>"Fri, 02 Jan 2015 11:03:45 GMT", "Cache-Control"=>"public, max-stale=1000, no-cache=\"Cookie\"", "Last-Modified"=>"Fri, 02 Jan 2015 11:03:45 GMT", "ETag"=>"\"validEtag\""} 178 | end 179 | 180 | def test_remove_warning_1xx 181 | one_result = CacheRules.helper_remove_warning_1xx.call 'Warning', ["199 - \"This is a test\"", "299 - Hello World"] 182 | nil_result = CacheRules.helper_remove_warning_1xx.call 'Warning', ["199 - \"This is a test\""] 183 | 184 | assert_equal one_result, {"Warning"=>["299 - Hello World"]} 185 | assert_nil nil_result 186 | end 187 | 188 | def test_helper_has_star 189 | ystar = CacheRules.helper_has_star "*" 190 | nstar = CacheRules.helper_has_star "myetag" 191 | ymulti = CacheRules.helper_has_star ["test", "*"] 192 | nmulti = CacheRules.helper_has_star ["test1", "test2"] 193 | 194 | assert_kind_of TrueClass, ystar 195 | assert_kind_of FalseClass, nstar 196 | assert_kind_of TrueClass, ymulti 197 | assert_kind_of FalseClass, nmulti 198 | end 199 | 200 | def test_combine_etags 201 | star = CacheRules.helper_combine_etags @request_headers, @cached_headers 202 | match = CacheRules.helper_combine_etags @request_headers_combine, @cached_headers 203 | nomatch = CacheRules.helper_combine_etags @request_headers_combine_nothing, @cached_headers 204 | 205 | assert_equal star, "*" 206 | assert_equal match, "\"myetag\", \"validEtag\"" 207 | assert_equal nomatch, "\"validEtag\"" 208 | end 209 | 210 | def test_timestamp 211 | request = CacheRules.helper_timestamp @request_headers, @cached_headers 212 | cached = CacheRules.helper_timestamp @request_headers_combine_nothing, @cached_headers 213 | nothing = CacheRules.helper_timestamp({}, {}) 214 | 215 | assert_nil nothing 216 | 217 | assert_equal request, "Thu, 01 Jan 2015 07:03:42 GMT" 218 | assert_equal cached, "Fri, 02 Jan 2015 11:03:45 GMT" 219 | end 220 | 221 | def test_etag 222 | etag_match = CacheRules.helper_etag @request_headers, @cached_headers 223 | etag_nomatch = CacheRules.helper_etag @request_headers_combine_nothing, @cached_headers 224 | etag_combined = CacheRules.helper_etag @request_headers_combine, @cached_headers 225 | etag_nothing = CacheRules.helper_etag @request_headers_nothing, @cached_headers 226 | 227 | assert_kind_of TrueClass, etag_match 228 | assert_nil etag_nomatch 229 | assert_kind_of TrueClass, etag_combined 230 | assert_kind_of FalseClass, etag_nothing 231 | end 232 | 233 | def test_last_modified 234 | guard = CacheRules.helper_last_modified @request_headers_combine_nothing, @cached_headers 235 | rule1 = CacheRules.helper_last_modified @request_headers_new, @cached_headers 236 | rule2 = CacheRules.helper_last_modified @request_headers_new, @cached_rule2 237 | rule3 = CacheRules.helper_last_modified @request_headers_new, @cached_rule3 238 | rule4 = CacheRules.helper_last_modified @request_headers_60, @cached_headers 239 | 240 | assert_nil guard 241 | assert_kind_of TrueClass, rule1 242 | assert_kind_of TrueClass, rule2 243 | assert_kind_of TrueClass, rule3 244 | assert_kind_of TrueClass, rule4 245 | end 246 | 247 | def test_validate_allow_stale 248 | no_cache = CacheRules.helper_validate_allow_stale @request_headers, @cached_headers 249 | no_store = CacheRules.helper_validate_allow_stale({"Cache-Control" => {"no-store"=>{"token"=>nil, "quoted_string"=>nil}}}, @cached_headers) 250 | pragma = CacheRules.helper_validate_allow_stale({"Pragma" => "no-cache"}, @cached_rule2) 251 | 252 | cached_no_cache = CacheRules.helper_validate_allow_stale({}, @cached_headers) 253 | cached_no_store = CacheRules.helper_validate_allow_stale({}, {"Cache-Control" => {"no-store"=>{"token"=>nil, "quoted_string"=>nil}}}) 254 | cached_must_rev = CacheRules.helper_validate_allow_stale({}, {"Cache-Control" => {"must-revalidate"=>{"token"=>nil, "quoted_string"=>nil}}}) 255 | cached_s_maxage = CacheRules.helper_validate_allow_stale({}, {"Cache-Control" => {"s-maxage"=>{"token"=>nil, "quoted_string"=>nil}}}) 256 | cached_proxy_re = CacheRules.helper_validate_allow_stale({}, {"Cache-Control" => {"proxy-revalidate"=>{"token"=>nil, "quoted_string"=>nil}}}) 257 | 258 | nothing = CacheRules.helper_validate_allow_stale({}, @cached_rule2) 259 | 260 | assert_kind_of TrueClass, no_cache 261 | assert_kind_of TrueClass, no_store 262 | assert_kind_of TrueClass, pragma 263 | 264 | assert_kind_of TrueClass, cached_no_cache 265 | assert_kind_of TrueClass, cached_no_store 266 | assert_kind_of TrueClass, cached_must_rev 267 | assert_kind_of TrueClass, cached_s_maxage 268 | assert_kind_of TrueClass, cached_proxy_re 269 | 270 | assert_nil nothing 271 | end 272 | 273 | def test_apparent_age 274 | older = CacheRules.helper_apparent_age(1420196565, 1420196505).call 275 | current = CacheRules.helper_apparent_age(1420196565, 1420196565).call 276 | newer = CacheRules.helper_apparent_age(1420196505, 1420196565).call 277 | 278 | assert_equal older, 60 279 | assert_equal current, 0 280 | assert_equal newer, 0 281 | end 282 | 283 | def test_corrected_age_value 284 | zero = CacheRules.helper_corrected_age_value(1420196565, 1420196565, 0).call 285 | one_hundred = CacheRules.helper_corrected_age_value(1420196565, 1420196565, 100).call 286 | sixty = CacheRules.helper_corrected_age_value(1420196565, 1420196505, 0).call 287 | one_sixty = CacheRules.helper_corrected_age_value(1420196565, 1420196505, 100).call 288 | impossible = CacheRules.helper_corrected_age_value(1420196505, 1420196565, 0).call 289 | 290 | assert_equal zero, 0 291 | assert_equal one_hundred, 100 292 | assert_equal sixty, 60 293 | assert_equal one_sixty, 160 294 | assert_equal impossible, -60 295 | end 296 | 297 | def test_corrected_initial_age 298 | via_good1 = {'Via' => ['HTTP/1.1 test.com'], 'Age' => 100} 299 | via_good2 = {'Via' => ['1.1 test.com'], 'Age' => 200} 300 | via_good3 = {'Via' => ['HTTP/1.1'], 'Age' => 300} 301 | via_good4 = {'Via' => ['1.1'], 'Age' => 400} 302 | via_bad = {'Via' => ['HTTP/1.1', '1.0'], 'Age' => 500} 303 | via_noage = {'Via' => ['1.1']} 304 | 305 | apparent_age = Proc.new { 1000 } 306 | 307 | good1 = CacheRules.helper_corrected_initial_age(via_good1, Proc.new { 100 }, apparent_age).call 308 | good2 = CacheRules.helper_corrected_initial_age(via_good2, Proc.new { 200 }, apparent_age).call 309 | good3 = CacheRules.helper_corrected_initial_age(via_good3, Proc.new { 300 }, apparent_age).call 310 | good4 = CacheRules.helper_corrected_initial_age(via_good4, Proc.new { 400 }, apparent_age).call 311 | bad = CacheRules.helper_corrected_initial_age(via_bad, Proc.new { 500 }, apparent_age).call 312 | noage = CacheRules.helper_corrected_initial_age(via_noage, Proc.new { 42 }, apparent_age).call 313 | 314 | assert_kind_of Integer, good1 315 | 316 | assert_equal good1, 100 317 | assert_equal good2, 200 318 | assert_equal good3, 300 319 | assert_equal good4, 400 320 | assert_equal bad, 1000 321 | assert_equal noage, 1000 322 | end 323 | 324 | def test_current_age 325 | now = @request_headers_new['If-Modified-Since']['timestamp'] 326 | 327 | current_age = CacheRules.helper_current_age now, @cached_headers 328 | 329 | assert_equal current_age, 165600 330 | end 331 | 332 | def test_freshness_lifetime 333 | cur_time = Time.now.gmtime.to_i 334 | current_age = CacheRules.helper_current_age cur_time, @cached_headers 335 | freshness = CacheRules.helper_freshness_lifetime.call @cached_headers 336 | 337 | assert_kind_of Array, freshness 338 | assert_equal freshness[0], 0 339 | assert_in_delta freshness[1], current_age, 2 340 | end 341 | 342 | def test_explicit 343 | s_maxage = CacheRules.helper_explicit({'Cache-Control' => {'s-maxage'=>{'token'=>60}}}) 344 | max_age = CacheRules.helper_explicit({'Cache-Control' => {'max-age'=>{'token'=>100}}}) 345 | expires = CacheRules.helper_explicit({'Expires' => {'timestamp'=>1420196565}, 'Date'=> {'timestamp'=>1420196565}}) 346 | noop = CacheRules.helper_explicit({}) 347 | 348 | assert_equal s_maxage, 60 349 | assert_equal max_age, 100 350 | assert_equal expires, 0 351 | assert_nil noop 352 | end 353 | 354 | def test_heuristic 355 | now = @request_headers_new['If-Modified-Since']['timestamp'] 356 | 357 | last_modified = CacheRules.helper_heuristic now, @cached_headers, 100 358 | not_public = CacheRules.helper_heuristic now, @cached_rule2, 100 359 | too_old = CacheRules.helper_heuristic now, @cached_headers, 86401 360 | noop = CacheRules.helper_heuristic(now, {}, 42) 361 | 362 | assert_equal last_modified, 16560 363 | assert_equal not_public, 0 364 | assert_equal too_old, 0 365 | assert_equal noop, 0 366 | end 367 | 368 | def test_max_stale 369 | stale = CacheRules.helper_max_stale.call @request_headers['Cache-Control'], 0, 0 370 | fresh = CacheRules.helper_max_stale.call @request_headers['Cache-Control'], 0, 2000 371 | noop = CacheRules.helper_max_stale.call @request_headers_nothing, 0, 0 372 | notoken = CacheRules.helper_max_stale.call({'max-stale'=>{'token'=>nil}}, 0, 0) 373 | 374 | assert_kind_of TrueClass, stale 375 | assert_kind_of FalseClass, fresh 376 | assert_kind_of TrueClass, noop 377 | assert_kind_of TrueClass, notoken 378 | end 379 | 380 | def test_min_fresh 381 | min = CacheRules.helper_min_fresh.call({'min-fresh'=>{'token'=>1000}}, 0, 0) 382 | fresh = CacheRules.helper_min_fresh.call({'min-fresh'=>{'token'=>"1000"}}, 2000, 0) 383 | noop = CacheRules.helper_min_fresh.call @request_headers_nothing, 0, 0 384 | 385 | assert_kind_of FalseClass, min 386 | assert_kind_of TrueClass, fresh 387 | assert_nil noop 388 | end 389 | 390 | def test_no_cache 391 | quoted = CacheRules.helper_no_cache.call @cached_headers 392 | not_quoted = CacheRules.helper_no_cache.call({'Cache-Control'=>{'no-cache'=>{'quoted_string'=>""}}}) 393 | noop = CacheRules.helper_no_cache.call({'Cache-Control'=>{'no-cache'=>nil}}) 394 | 395 | assert_kind_of TrueClass, quoted 396 | assert_kind_of FalseClass, not_quoted 397 | assert_nil noop 398 | end 399 | 400 | def test_make_request 401 | result = CacheRules.helper_make_request 'fake http object', 'fake request object' 402 | 403 | assert_kind_of Proc, result 404 | assert_equal result.arity, 0 405 | end 406 | 407 | def test_make_request_timer 408 | result = CacheRules.helper_make_request_timer 409 | 410 | assert_kind_of Proc, result 411 | assert_equal result.arity, 3 412 | end 413 | 414 | def test_response_headers 415 | http = Net::HTTPResponse.new(1.1, 200, "OK") 416 | http.add_field "Warning", "299 - \"Hello World\"" 417 | 418 | response_no_date = {:req_date=>"Mon, 02 Feb 2015 04:21:37 GMT", :res=>http, :res_date=>"Mon, 02 Feb 2015 04:21:40 GMT"} 419 | result_no_date = CacheRules.helper_response_headers.call response_no_date 420 | 421 | http.add_field "Date", "Fri, 02 Jan 2015 11:03:45 GMT" 422 | response_with_date = {:req_date=>"Mon, 02 Feb 2015 04:21:37 GMT", :res=>http, :res_date=>"Mon, 02 Feb 2015 04:21:40 GMT"} 423 | result_with_date = CacheRules.helper_response_headers.call response_with_date 424 | 425 | assert_equal result_no_date, {"Warning"=>["299 - \"Hello World\""], "X-Cache-Req-Date"=>"Mon, 02 Feb 2015 04:21:37 GMT", "X-Cache-Res-Date"=>"Mon, 02 Feb 2015 04:21:40 GMT", "Status"=>200} 426 | assert_equal result_with_date, {"Warning"=>["299 - \"Hello World\""], "Date"=>"Mon, 02 Feb 2015 04:21:40 GMT", "X-Cache-Req-Date"=>"Mon, 02 Feb 2015 04:21:37 GMT", "X-Cache-Res-Date"=>"Mon, 02 Feb 2015 04:21:40 GMT", "Status"=>200} 427 | end 428 | 429 | end 430 | -------------------------------------------------------------------------------- /test/test_regressions.rb: -------------------------------------------------------------------------------- 1 | class TestRegressions < MiniTest::Test 2 | 3 | # 4 | # Bugfix tests to ensure we don't allow regressions 5 | # 6 | # https://github.com/aw/CacheRules/issues 7 | 8 | def setup 9 | @cached_headers = { 10 | :cached => { 11 | "Age" => "99999", 12 | "Date" => {"httpdate"=>"Thu, 01 Jan 2015 07:03:45 GMT", "timestamp"=>1420095825}, 13 | "Cache-Control" => { 14 | "public" => {"token"=>nil, "quoted_string"=>nil}, 15 | "max-age" => {"token"=>"10", "quoted_string"=>nil} 16 | }, 17 | "X-Cache-Req-Date" => {"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, 18 | "X-Cache-Res-Date" => {"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625} 19 | } 20 | } 21 | end 22 | 23 | # https://github.com/aw/CacheRules/issues/5 24 | def test_bugfix_5_age_header_string_integer 25 | age_string = CacheRules.action_add_age @cached_headers 26 | age_integer = CacheRules.helper_corrected_initial_age({}, Proc.new { 100 }, Proc.new { 99 }).call 27 | 28 | assert_kind_of String, age_string['Age'] 29 | assert_kind_of Integer, age_integer 30 | end 31 | 32 | # https://github.com/aw/CacheRules/issues/7 33 | def test_bugfix_7_invalid_validation_of_max_stale 34 | request = {"Host"=>"test.url"} 35 | 36 | no_max_stale = CacheRules.helper_max_stale.call request, 0, 0 37 | 38 | assert_kind_of TrueClass, no_max_stale 39 | end 40 | 41 | # https://github.com/aw/CacheRules/issues/8 42 | def test_bugfix_8_errors_caused_by_empty_http_headers 43 | isnil = CacheRules.clean.call({'Content-Type'=>nil}) 44 | isempty = CacheRules.clean.call({'Content-Type'=>''}) 45 | 46 | assert_equal isnil, [] 47 | assert_equal isempty, [] 48 | end 49 | 50 | # https://github.com/aw/CacheRules/issues/10 51 | def test_bugfix_10_request_header_max_age_is_checked 52 | request_maxage = CacheRules.validate_no_cache?({ 53 | :cached => @cached_headers[:cached], 54 | :request => {"Cache-Control" => {"max-age" => {"token"=>0, "quoted_string"=>nil} } } 55 | }) 56 | current = CacheRules.validate_no_cache?({ 57 | :cached => @cached_headers[:cached], 58 | :request => {"Cache-Control" => {"max-age" => {"token"=>1000, "quoted_string"=>nil} } } 59 | }) 60 | cached_max_age = CacheRules.validate_expired?({ 61 | :cached => @cached_headers[:cached], 62 | :request => {} 63 | }) 64 | 65 | assert_equal 1, request_maxage 66 | assert_equal 1, current 67 | assert_equal 1, cached_max_age 68 | end 69 | 70 | # https://github.com/aw/CacheRules/issues/13 71 | def test_bugfix_13_revalidate_without_preconditions 72 | if_none_match = CacheRules.helper_has_preconditions.({'If-None-Match'=>'*'},{}) 73 | etag = CacheRules.helper_has_preconditions.({}, {'ETag'=>["abcdefg"]}) 74 | last_modified = CacheRules.helper_has_preconditions.({}, {'Last-Modified'=>{"httpdate"=>"Fri, 02 Jan 2015 11:03:45 GMT", "timestamp"=>1420196625}}) 75 | empty = CacheRules.helper_has_preconditions.({}, {}) 76 | no_precond = CacheRules.revalidate_response('ftp://test.url/test1', {}, {}) 77 | 78 | assert_equal "*", if_none_match 79 | assert_equal ["abcdefg"], etag 80 | assert_equal({"httpdate"=>"Fri, 02 Jan 2015 11:03:45 GMT", "timestamp"=>1420196625}, last_modified) 81 | assert_nil empty 82 | 83 | assert_equal no_precond[:code], 504 84 | assert_equal no_precond[:body], 'Gateway Timeout' 85 | assert_nil no_precond[:error] 86 | end 87 | 88 | end 89 | -------------------------------------------------------------------------------- /test/test_tables.rb: -------------------------------------------------------------------------------- 1 | class TestTables < MiniTest::Test 2 | 3 | def setup 4 | @request_map = {"0000000"=>0, "0000001"=>0, "0000010"=>1, "0000011"=>1, "0000100"=>0, "0000101"=>0, "0000110"=>1, "0000111"=>1, "0001000"=>0, "0001001"=>0, "0001010"=>1, "0001011"=>1, "0001100"=>0, "0001101"=>0, "0001110"=>1, "0001111"=>1, "0010000"=>0, "0010001"=>0, "0010010"=>1, "0010011"=>1, "0010100"=>0, "0010101"=>0, "0010110"=>1, "0010111"=>1, "0011000"=>0, "0011001"=>0, "0011010"=>1, "0011011"=>1, "0011100"=>0, "0011101"=>0, "0011110"=>1, "0011111"=>1, "0100000"=>0, "0100001"=>0, "0100010"=>1, "0100011"=>1, "0100100"=>0, "0100101"=>0, "0100110"=>1, "0100111"=>1, "0101000"=>0, "0101001"=>0, "0101010"=>1, "0101011"=>1, "0101100"=>0, "0101101"=>0, "0101110"=>1, "0101111"=>1, "0110000"=>0, "0110001"=>0, "0110010"=>1, "0110011"=>1, "0110100"=>0, "0110101"=>0, "0110110"=>1, "0110111"=>1, "0111000"=>0, "0111001"=>0, "0111010"=>1, "0111011"=>1, "0111100"=>0, "0111101"=>0, "0111110"=>1, "0111111"=>1, "1000000"=>2, "1000001"=>2, "1000010"=>2, "1000011"=>2, "1000100"=>6, "1000101"=>4, "1000110"=>6, "1000111"=>4, "1001000"=>3, "1001001"=>3, "1001010"=>3, "1001011"=>3, "1001100"=>6, "1001101"=>5, "1001110"=>6, "1001111"=>5, "1010000"=>8, "1010001"=>8, "1010010"=>8, "1010011"=>8, "1010100"=>8, "1010101"=>8, "1010110"=>8, "1010111"=>8, "1011000"=>8, "1011001"=>8, "1011010"=>8, "1011011"=>8, "1011100"=>8, "1011101"=>8, "1011110"=>8, "1011111"=>8, "1100000"=>2, "1100001"=>2, "1100010"=>2, "1100011"=>2, "1100100"=>7, "1100101"=>7, "1100110"=>7, "1100111"=>7, "1101000"=>3, "1101001"=>3, "1101010"=>3, "1101011"=>3, "1101100"=>7, "1101101"=>7, "1101110"=>7, "1101111"=>7, "1110000"=>8, "1110001"=>8, "1110010"=>8, "1110011"=>8, "1110100"=>8, "1110101"=>8, "1110110"=>8, "1110111"=>8, "1111000"=>8, "1111001"=>8, "1111010"=>8, "1111011"=>8, "1111100"=>8, "1111101"=>8, "1111110"=>8, "1111111"=>8} 5 | @response_map = {"000"=>0, "001"=>1, "010"=>0, "011"=>1, "100"=>2, "101"=>2, "110"=>3, "111"=>4} 6 | end 7 | 8 | def test_x_value 9 | assert_kind_of NilClass, CacheRules::X 10 | 11 | assert_nil CacheRules::X 12 | end 13 | 14 | def test_request_table 15 | assert_kind_of Hash, CacheRules::REQUEST_TABLE 16 | 17 | assert_includes CacheRules::REQUEST_TABLE, :conditions 18 | assert_includes CacheRules::REQUEST_TABLE, :actions 19 | 20 | assert_equal CacheRules::REQUEST_TABLE[:conditions].length, 7 21 | assert_equal CacheRules::REQUEST_TABLE[:actions].length, 6 22 | 23 | conditions = CacheRules::REQUEST_TABLE[:conditions].keys 24 | actions = CacheRules::REQUEST_TABLE[:actions].keys 25 | assert_equal conditions, %w(cached must_revalidate no_cache precond_match expired only_if_cached allow_stale) 26 | assert_equal actions, %w(revalidate add_age add_x_cache add_warning add_status return_body) 27 | end 28 | 29 | def test_response_table 30 | assert_kind_of Hash, CacheRules::RESPONSE_TABLE 31 | 32 | assert_includes CacheRules::RESPONSE_TABLE, :conditions 33 | assert_includes CacheRules::RESPONSE_TABLE, :actions 34 | 35 | assert_equal CacheRules::RESPONSE_TABLE[:conditions].length, 3 36 | assert_equal CacheRules::RESPONSE_TABLE[:actions].length, 6 37 | 38 | conditions = CacheRules::RESPONSE_TABLE[:conditions].keys 39 | actions = CacheRules::RESPONSE_TABLE[:actions].keys 40 | assert_equal conditions, %w(is_error allow_stale validator_match) 41 | assert_equal actions, %w(revalidate add_age add_x_cache add_warning add_status return_body) 42 | end 43 | 44 | def test_request_map_is_correct 45 | assert_kind_of Hash, CacheRules::REQUEST_MAP 46 | 47 | assert_equal CacheRules::REQUEST_MAP.length, 128 48 | assert_equal CacheRules::REQUEST_MAP, @request_map 49 | 50 | assert_equal CacheRules::REQUEST_MAP["0000000"], 0 51 | assert_equal CacheRules::REQUEST_MAP["0000010"], 1 52 | assert_equal CacheRules::REQUEST_MAP["1000000"], 2 53 | assert_equal CacheRules::REQUEST_MAP["1001000"], 3 54 | assert_equal CacheRules::REQUEST_MAP["1000101"], 4 55 | assert_equal CacheRules::REQUEST_MAP["1001101"], 5 56 | assert_equal CacheRules::REQUEST_MAP["1000100"], 6 57 | assert_equal CacheRules::REQUEST_MAP["1100100"], 7 58 | assert_equal CacheRules::REQUEST_MAP["1010000"], 8 59 | end 60 | 61 | def test_response_map_is_correct 62 | assert_kind_of Hash, CacheRules::RESPONSE_MAP 63 | 64 | assert_equal CacheRules::RESPONSE_MAP.length, 8 65 | assert_equal CacheRules::RESPONSE_MAP, @response_map 66 | 67 | assert_equal CacheRules::RESPONSE_MAP["000"], 0 68 | assert_equal CacheRules::RESPONSE_MAP["001"], 1 69 | assert_equal CacheRules::RESPONSE_MAP["100"], 2 70 | assert_equal CacheRules::RESPONSE_MAP["110"], 3 71 | assert_equal CacheRules::RESPONSE_MAP["111"], 4 72 | end 73 | 74 | end 75 | -------------------------------------------------------------------------------- /test/test_validations.rb: -------------------------------------------------------------------------------- 1 | class TestValidations < MiniTest::Test 2 | 3 | def setup 4 | cache_control = {"max-stale"=>{"token"=>9999999, "quoted_string"=>nil},"only-if-cached"=>{"token"=>nil, "quoted_string"=>nil}} 5 | if_modified = {"httpdate"=>"Thu, 01 Jan 2015 07:03:42 GMT", "timestamp"=>1420095822} 6 | date = {"httpdate"=>"Fri, 02 Jan 2015 11:03:45 GMT", "timestamp"=>1420196625} 7 | next_date = {"httpdate"=>"Sat, 03 Jan 2037 07:03:45 GMT", "timestamp"=>2114579025} 8 | if_modified_new = {"httpdate"=>"Sun, 04 Jan 2015 09:03:45 GMT", "timestamp"=>1420362225} 9 | cache_min_fresh = {"min-fresh"=>{"token"=>9999999, "quoted_string"=>nil},"only-if-cached"=>{"token"=>nil, "quoted_string"=>nil}} 10 | 11 | @headers = { 12 | :request => { 13 | "If-Modified-Since" => if_modified, 14 | "Cache-Control" => cache_control, 15 | "If-None-Match" => ["\"myetag\"", "\"validEtag\""] 16 | }, 17 | :cached => { 18 | "Date" => date, 19 | "Cache-Control" => { 20 | "public" => {"token"=>nil, "quoted_string"=>nil}, 21 | "max-stale" => {"token"=>"1000", "quoted_string"=>nil} 22 | }, 23 | "Last-Modified" => {"httpdate"=>"Thu, 01 Jan 2015 07:03:42 GMT", "timestamp"=>1420095822}, 24 | "ETag" => "\"validEtag\"", 25 | "X-Cache-Req-Date" => date, 26 | "X-Cache-Res-Date" => date 27 | } 28 | } 29 | @headers_stale = { 30 | :request => { 31 | "If-Modified-Since" => if_modified, 32 | "Cache-Control" => cache_control 33 | }, 34 | :cached => { 35 | "Date" => {"httpdate"=>"Wed, 21 Feb 2018 04:13:57 GMT", "timestamp"=>1519186437}, 36 | "Cache-Control" => { 37 | "public" => {"token"=>nil, "quoted_string"=>nil}, 38 | "max-stale" => {"token"=>"100", "quoted_string"=>nil} 39 | }, 40 | "Last-Modified" => {"httpdate"=>"Wed, 21 Feb 2018 04:13:57 GMT", "timestamp"=>1519186437}, 41 | "X-Cache-Req-Date" => {"httpdate"=>"Wed, 21 Feb 2018 04:13:57 GMT", "timestamp"=>1519186437}, 42 | "X-Cache-Res-Date" => {"httpdate"=>"Wed, 21 Feb 2018 04:13:57 GMT", "timestamp"=>1519186437} 43 | } 44 | } 45 | @headers_noetag = { 46 | :request => { 47 | "If-None-Match" => ["\"myetag\""] 48 | }, 49 | :cached => { 50 | "Date" => date, 51 | "Cache-Control" => { 52 | "public" => {"token"=>nil, "quoted_string"=>nil}, 53 | "no-cache" => {"token"=>nil, "quoted_string"=>"Cookie"}, 54 | "proxy-revalidate" => {"token"=>nil, "quoted_string"=>nil} 55 | }, 56 | "ETag" => "\"validEtag\"", 57 | "X-Cache-Req-Date" => date, 58 | "X-Cache-Res-Date" => date 59 | } 60 | } 61 | @no_headers = { 62 | :request => {}, 63 | :cached => {} 64 | } 65 | @headers_nothing = { 66 | :request => {"If-None-Match" => ["\"myetag\""] }, 67 | :cached => { 68 | "Expires" => next_date, 69 | "Date" => date, 70 | "X-Cache-Req-Date" => date, 71 | "X-Cache-Res-Date" => date, 72 | "Cache-Control" => { 73 | "must-revalidate" => {"token"=>nil, "quoted_string"=>nil} 74 | } 75 | } 76 | } 77 | @cached_rule2 = { 78 | :request => { 79 | "If-Modified-Since" => if_modified_new, 80 | "Cache-Control" => cache_min_fresh 81 | }, 82 | :cached => { 83 | "Date" => next_date, 84 | "X-Cache-Req-Date" => next_date, 85 | "X-Cache-Res-Date" => next_date 86 | } 87 | } 88 | end 89 | 90 | def test_to_bit 91 | one = CacheRules.to_bit { true } 92 | zero = CacheRules.to_bit { false } 93 | 94 | assert_equal one, 1 95 | assert_equal zero, 0 96 | end 97 | 98 | def test_cached 99 | one = CacheRules.validate_cached? @headers 100 | zero = CacheRules.validate_cached? @no_headers 101 | 102 | assert_equal one, 1 103 | assert_equal zero, 0 104 | end 105 | 106 | def test_precond_match 107 | guard = CacheRules.validate_precond_match? @no_headers 108 | etag_one = CacheRules.validate_precond_match? @headers 109 | etag_zero = CacheRules.validate_precond_match? @headers_noetag 110 | mod_true = CacheRules.validate_precond_match? @cached_rule2 111 | mod_false = CacheRules.validate_precond_match? @headers_nothing 112 | 113 | assert_equal guard, 0 114 | assert_equal etag_one, 1 115 | assert_equal etag_zero, 0 116 | assert_equal mod_true, 1 117 | assert_equal mod_false, 0 118 | end 119 | 120 | def test_expired 121 | guard = CacheRules.validate_expired? @no_headers 122 | stale = CacheRules.validate_expired? @headers 123 | fresh = CacheRules.validate_expired? @headers_nothing 124 | 125 | assert_equal 0, guard 126 | assert_equal 1, stale 127 | assert_equal 0, fresh 128 | end 129 | 130 | def test_only_if_cached 131 | one = CacheRules.validate_only_if_cached? @headers 132 | zero = CacheRules.validate_only_if_cached? @headers_noetag 133 | 134 | assert_equal one, 1 135 | assert_equal zero, 0 136 | end 137 | 138 | def test_allow_stale 139 | guard1 = CacheRules.validate_allow_stale? @no_headers 140 | guard2 = CacheRules.validate_allow_stale? @headers_noetag 141 | max_stale = CacheRules.validate_allow_stale? @headers_stale 142 | min_fresh = CacheRules.validate_allow_stale? @cached_rule2 143 | nothing = CacheRules.validate_allow_stale? @headers_nothing 144 | 145 | assert_equal guard1, 0 146 | assert_equal guard2, 0 147 | assert_equal max_stale, 1 148 | assert_equal min_fresh, 1 149 | assert_equal nothing, 0 150 | end 151 | 152 | def test_must_revalidate 153 | guard = CacheRules.validate_must_revalidate? @no_headers 154 | must_revalidate = CacheRules.validate_must_revalidate? @headers_nothing 155 | proxy_revalidate= CacheRules.validate_must_revalidate? @headers_noetag 156 | nothing = CacheRules.validate_must_revalidate? @headers 157 | 158 | assert_equal guard, 1 159 | assert_equal must_revalidate, 1 160 | assert_equal proxy_revalidate, 1 161 | assert_equal nothing, 0 162 | end 163 | 164 | def test_no_cache 165 | headers1 = { 166 | :request => {'Cache-Control' => {'no-cache'=>{'token'=>nil}}}, 167 | :cached => {} 168 | } 169 | headers2 = { 170 | :request => {}, 171 | :cached => {'Cache-Control' => {'no-cache'=>{'quoted_string'=>"Cookie"}}, "Date" => {"httpdate" => "Thu, 01 Jan 2015 07:03:45 GMT", "timestamp"=>1420095825}, "X-Cache-Req-Date" => {"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "X-Cache-Res-Date" => {"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}} 172 | } 173 | headers2_nil = { 174 | :request => {}, 175 | :cached => {'Cache-Control' => {'no-cache'=>{'quoted_string'=>nil}}, "Date" => {"httpdate" => "Thu, 01 Jan 2015 07:03:45 GMT", "timestamp"=>1420095825}, "X-Cache-Req-Date" => {"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "X-Cache-Res-Date" => {"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}} 176 | } 177 | headers3 = { 178 | :request => {}, 179 | :cached => {'Cache-Control' => {'s-maxage'=>{'token'=>"0"}}, "Date" => {"httpdate" => "Thu, 01 Jan 2015 07:03:45 GMT", "timestamp"=>1420095825}, "X-Cache-Req-Date" => {"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "X-Cache-Res-Date" => {"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}} 180 | } 181 | headers4 = { 182 | :request => {}, 183 | :cached => {'Cache-Control' => {'max-age'=>{'token'=>0}}, "Date" => {"httpdate" => "Thu, 01 Jan 2015 07:03:45 GMT", "timestamp"=>1420095825}, "X-Cache-Req-Date" => {"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "X-Cache-Res-Date" => {"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}} 184 | } 185 | headers5 = { 186 | :request => {'Pragma' => {'no-cache'=>{'token'=>nil}}}, 187 | :cached => {'Cache-Control' => {}, "Date" => {"httpdate" => "Thu, 01 Jan 2015 07:03:45 GMT", "timestamp"=>1420095825}, "X-Cache-Req-Date" => {"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}, "X-Cache-Res-Date" => {"httpdate"=>"Sat, 03 Jan 2015 07:03:45 GMT", "timestamp"=>1420268625}} 188 | } 189 | 190 | guard = CacheRules.validate_no_cache? @no_headers 191 | no_cache1 = CacheRules.validate_no_cache? headers1 192 | no_cache2 = CacheRules.validate_no_cache? headers2 193 | no_cache3 = CacheRules.validate_no_cache? headers2_nil 194 | s_maxage = CacheRules.validate_no_cache? headers3 195 | maxage = CacheRules.validate_no_cache? headers4 196 | pragma = CacheRules.validate_no_cache? headers5 197 | nothing = CacheRules.validate_no_cache? @headers 198 | 199 | assert_equal guard, 1 200 | assert_equal no_cache1, 1 201 | assert_equal no_cache2, 1 202 | assert_equal no_cache3, 1 203 | assert_equal s_maxage, 1 204 | assert_equal maxage, 1 205 | assert_equal pragma, 1 206 | assert_equal nothing, 0 207 | end 208 | 209 | def test_is_error 210 | not_error1 = CacheRules.validate_is_error?({:response => {'Status'=>499}}) 211 | not_error2 = CacheRules.validate_is_error?({:response => {'Status'=>600}}) 212 | is_error1 = CacheRules.validate_is_error?({:response => {'Status'=>500}}) 213 | is_error2 = CacheRules.validate_is_error?({:response => {'Status'=>550}}) 214 | is_error3 = CacheRules.validate_is_error?({:response => {'Status'=>599}}) 215 | 216 | assert_equal not_error1, 0 217 | assert_equal not_error2, 0 218 | assert_equal is_error1, 1 219 | assert_equal is_error2, 1 220 | assert_equal is_error3, 1 221 | end 222 | 223 | def test_validator_match 224 | match = CacheRules.validate_validator_match?({:request => {'If-None-Match'=>["\"myetag\""]}, :response => {'ETag'=>"\"myetag\""}}) 225 | match1 = CacheRules.validate_validator_match?({:request => {'If-None-Match'=>["*"]}, :response => {'ETag'=>"\"myetag\""}}) 226 | nomatch = CacheRules.validate_validator_match?({:request => {'If-None-Match'=>["\"myetag\""]}, :response => {}}) 227 | 228 | assert_equal match, 1 229 | assert_equal 1, match1 230 | assert_equal nomatch, 0 231 | end 232 | 233 | end 234 | --------------------------------------------------------------------------------