├── .github └── workflows │ ├── lint.yml │ └── main.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── bin ├── rake └── rspec ├── examples ├── github.rb └── parallel.rb ├── faraday-http-cache.gemspec ├── gemfiles └── rubocop.gemfile ├── lib ├── faraday-http-cache.rb └── faraday │ ├── http_cache.rb │ └── http_cache │ ├── cache_control.rb │ ├── memory_store.rb │ ├── request.rb │ ├── response.rb │ ├── storage.rb │ ├── strategies.rb │ └── strategies │ ├── base_strategy.rb │ ├── by_url.rb │ └── by_vary.rb ├── log └── .gitkeep └── spec ├── binary_spec.rb ├── cache_control_spec.rb ├── http_cache_spec.rb ├── instrumentation_spec.rb ├── json_spec.rb ├── request_spec.rb ├── response_spec.rb ├── spec_helper.rb ├── storage_spec.rb ├── strategies ├── base_strategy_spec.rb ├── by_url_spec.rb └── by_vary_spec.rb ├── support ├── empty.png ├── test_app.rb └── test_server.rb └── validation_spec.rb /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint Ruby 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | pull_request: 9 | 10 | jobs: 11 | rubocop: 12 | runs-on: ubuntu-latest 13 | env: 14 | BUNDLE_GEMFILE: 'gemfiles/rubocop.gemfile' 15 | 16 | steps: 17 | - uses: actions/checkout@v2 18 | - uses: ruby/setup-ruby@v1 19 | with: 20 | ruby-version: '3.0' 21 | bundler-cache: true 22 | - name: Lint Ruby code with Rubocop 23 | run: bundle exec rubocop 24 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Test Ruby 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | pull_request: 9 | 10 | jobs: 11 | rspec: 12 | runs-on: ubuntu-latest 13 | env: 14 | BUNDLE_JOBS: 4 15 | BUNDLE_RETRY: 3 16 | FARADAY_VERSION: ${{ matrix.faraday }} 17 | FARADAY_ADAPTER: ${{ matrix.faraday_adapter }} 18 | ACTIVESUPPORT_VERSION: ${{ matrix.activesupport }} 19 | strategy: 20 | fail-fast: false 21 | matrix: 22 | ruby: ['2.4', '2.5', '2.6', '2.7', '3.0', '3.1'] 23 | faraday_adapter: [net_http, em_http] 24 | faraday: ['~> 0.8.0', '~> 0.15.0', '~> 0.17.3', '~> 1.0', '~> 2.0'] 25 | exclude: 26 | # Faraday 2 requires Ruby 2.6+ 27 | - ruby: '2.5' 28 | faraday: '~> 2.0' 29 | - ruby: '2.4' 30 | faraday: '~> 2.0' 31 | # Ruby 3.0+ requires Faraday >= 0.17.3 32 | - ruby: '3.0' 33 | faraday: '~> 0.8.0' 34 | - ruby: '3.0' 35 | faraday: '~> 0.15.0' 36 | - ruby: '3.1' 37 | faraday: '~> 0.8.0' 38 | - ruby: '3.1' 39 | faraday: '~> 0.15.0' 40 | # faraday-em_http does not support Faraday 2.0+ 41 | - faraday: '~> 2.0' 42 | faraday_adapter: em_http 43 | 44 | steps: 45 | - uses: actions/checkout@v2 46 | - name: Set up Ruby 47 | uses: ruby/setup-ruby@v1 48 | with: 49 | ruby-version: ${{ matrix.ruby }} 50 | bundler-cache: true 51 | - name: Run RSpec 52 | run: bundle exec rspec 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | .ruby-version 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | log/ 12 | lib/bundler/man 13 | pkg 14 | rdoc 15 | spec/reports 16 | test/tmp 17 | test/version_tmp 18 | tmp 19 | *.lock 20 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --warnings 3 | --format progress 4 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | TargetRubyVersion: 2.4 3 | NewCops: disable 4 | SuggestExtensions: false 5 | Exclude: 6 | - 'bin/**/*' 7 | - 'tmp/**/*' 8 | - 'vendor/**/*' 9 | 10 | Layout/ArgumentAlignment: 11 | EnforcedStyle: with_fixed_indentation 12 | 13 | Layout/CaseIndentation: 14 | EnforcedStyle: end 15 | 16 | Style/AsciiComments: 17 | Enabled: false 18 | 19 | Style/CollectionMethods: 20 | Enabled: true 21 | PreferredMethods: 22 | inject: 'inject' 23 | 24 | Style/Documentation: 25 | Enabled: false 26 | 27 | Style/BlockDelimiters: 28 | Exclude: 29 | - spec/**/*_spec.rb 30 | 31 | Style/GuardClause: 32 | Enabled: false 33 | 34 | Style/IfUnlessModifier: 35 | Enabled: false 36 | 37 | Layout/SpaceInsideHashLiteralBraces: 38 | Enabled: false 39 | 40 | Style/Lambda: 41 | Enabled: false 42 | 43 | Style/RaiseArgs: 44 | Enabled: false 45 | 46 | Style/SignalException: 47 | Enabled: false 48 | 49 | Metrics/AbcSize: 50 | Enabled: false 51 | 52 | Metrics/ClassLength: 53 | Enabled: false 54 | 55 | Metrics/ModuleLength: 56 | Enabled: false 57 | 58 | Layout/LineLength: 59 | Enabled: false 60 | 61 | Metrics/BlockLength: 62 | Enabled: false 63 | 64 | Metrics/MethodLength: 65 | Enabled: false 66 | 67 | Style/SingleLineBlockParams: 68 | Enabled: false 69 | 70 | Layout/EndAlignment: 71 | EnforcedStyleAlignWith: variable 72 | 73 | Style/FormatString: 74 | Enabled: false 75 | 76 | Layout/MultilineOperationIndentation: 77 | EnforcedStyle: indented 78 | 79 | Style/WordArray: 80 | Enabled: false 81 | 82 | Style/RedundantSelf: 83 | Enabled: false 84 | 85 | Style/FrozenStringLiteralComment: 86 | Exclude: 87 | - examples/*.rb 88 | - Gemfile 89 | - lib/faraday-http-cache.rb 90 | - Rakefile 91 | 92 | Style/TrivialAccessors: 93 | AllowPredicates: true 94 | 95 | Style/NumericPredicate: 96 | Enabled: false 97 | 98 | Naming/FileName: 99 | Exclude: 100 | - lib/faraday-http-cache.rb 101 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Unreleased 2 | 3 | ## 2.5.1 (2024-01-16) 4 | 5 | * Support headers passed in using string keys when Vary header is in a different case via #137 (thanks @evman182) 6 | 7 | ## 2.5.0 (2023-04-27) 8 | 9 | * Add `reason_phrase` from the HTTP response to the data stored in the cache according to [RFC7230](https://www.rfc-editor.org/rfc/rfc7230#section-3.1.2) via [#134](https://github.com/sourcelevel/faraday-http-cache/pull/134) 10 | 11 | ## 2.4.1 (2022-08-08) 12 | 13 | * Require `Logger` in `BaseStrategy` via [#131](https://github.com/sourcelevel/faraday-http-cache/pull/131) 14 | * Use unique and sorted headers from the Vary header in `ByVary` strategy via [#132](https://github.com/sourcelevel/faraday-http-cache/pull/132) 15 | 16 | ## 2.4.0 (2022-06-07) 17 | 18 | * Introduced a new `strategy` option to support different cache storage strategies. 19 | * The original strategy moved from `Faraday::HttpCache::Storage` to `Faraday::HttpCache::Strategies::ByUrl`. 20 | * The new `Faraday::HttpCache::Strategies::ByVary` strategy uses headers from `Vary` header to generate cache keys. It also uses the index with `Vary` headers mapped to the request URL. 21 | * `Faraday::HttpCache::Storage` class deprecated. 22 | 23 | ## 2.3.0 (2022-05-25) 24 | 25 | * Added support for Ruby 3.0, 3.1. 26 | * Ruby version constraint changed to 2.4.0. 27 | 28 | ## 2.2.0 (2019-04-13) 29 | 30 | * Support for faraday 1.x 31 | 32 | ## 2.0.0 (2016-11-16) 33 | 34 | * Ruby version constraint changed to 2.1.0. 35 | * Changed `Faraday::HttpCache#initialize` to use keyword arguments instead of 36 | a `Hash`. 37 | 38 | ## 1.3.1 (2016-08-12) 39 | 40 | * Reject invalid `Date` response headers instead of letting the exception bubble. 41 | 42 | ## 1.3.0 (2016-03-24) 43 | 44 | * `no-cache` responses won't be treated as fresh and will always be revalidated. 45 | 46 | ## 1.2.2 (2015-08-27) 47 | 48 | * Update the `CACHE_STATUSES` to properly instrument requests with the `Cache-Control: no-store` header. 49 | 50 | ## 1.2.1 51 | 52 | * Update the `CACHE_STATUSES` to better instrument `invalid` and `uncacheable` responses. 53 | 54 | ## 1.2.0 (2015-08-14) 55 | 56 | * Deprecate the default instrumenter name `process_request.http_cache.faraday` 57 | in favor of `http_cache.faraday`. 58 | 59 | ## 1.1.1 (2015-06-04) 60 | 61 | * Added support for `:instrumenter_name` option. 62 | * 307 responses (`Temporary Redirects`) are now cached. 63 | * Do not crash on non RFC 2616 compliant `Expires` headers. 64 | 65 | ## 1.1.0 (2015-04-02) 66 | 67 | * Instrumentation supported. (by @dasch) 68 | * Illegal headers from `304` responses will be removed before updating the 69 | cached responses. (by @dasch) 70 | 71 | ## 1.0.1 (2015-01-30) 72 | 73 | * Fixed HTTP method matching that failed when using the `Marshal` serializer. 74 | (by @toddmazierski) 75 | 76 | ## 1.0.0 (2015-01-27) 77 | 78 | * Deprecated configuration API removed. 79 | * Better support for the caching mechanisms described in the RFC 7234, including: 80 | * Reworked the data structures that are stored in the underlying store to 81 | store responses under the same URL and HTTP method. 82 | * Cached responses are invalidated after a `PUT`/`POST`/`DELETE` request. 83 | * Support for the `Vary` header as a second logic to retrieve a stored response. 84 | 85 | ## 0.4.2 (2014-08-17) 86 | 87 | * Header values are explicitly part of the cache key for all requests. 88 | 89 | ## 0.4.1 (2014-06-26) 90 | 91 | * Encoding conversion exceptions will emit a log warning before raising through 92 | the middleware stack. Use `Marshal` instead of `JSON` to serialize such requests. 93 | * Compatible with latest ActiveSupport and Faraday versions. 94 | 95 | ## 0.4.0 (2014-01-30) 96 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ### Please read before contributing 2 | 3 | 1) If you find a security bug, **DO NOT** submit an issue here. Please send an e-mail to [opensource@sourcelevel.io](mailto:opensource@sourcelevel.io) instead. 4 | 5 | 3) Do a small search on the issues tracker before submitting your issue to see if it was already reported / fixed. In case it was not, create your report including `faraday` and `faraday-http-cache` versions. If you are getting exceptions, please include the full backtrace. 6 | 7 | That's it! The more information you give, the more easy it becomes for us to track it down and fix it. Ideal scenario would be adding the issue to `faraday-http-cache` test suite or to a sample application. 8 | 9 | Thanks! 10 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | 5 | install_if -> { ENV['FARADAY_VERSION'] } do 6 | gem 'faraday', ENV['FARADAY_VERSION'] 7 | end 8 | 9 | if /\D*([\d.]*)/.match(ENV.fetch('FARADAY_VERSION', ''))[1].start_with?('0') 10 | gem 'faraday_middleware' 11 | elsif ENV['FARADAY_ADAPTER'] == 'em_http' 12 | gem 'faraday-em_http' 13 | end 14 | 15 | gem 'activesupport', '>= 5.0' 16 | gem 'em-http-request', '~> 1.1' 17 | gem 'rake', '~> 13.0' 18 | gem 'rspec', '~> 3.1' 19 | gem 'sinatra', '~> 2.0' 20 | gem 'webrick' 21 | 22 | eval_gemfile 'gemfiles/rubocop.gemfile' 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Licensed under the Apache License, Version 2.0 (the "License"); 2 | you may not use this file except in compliance with the License. 3 | You may obtain a copy of the License at 4 | 5 | http://www.apache.org/licenses/LICENSE-2.0 6 | 7 | Unless required by applicable law or agreed to in writing, software 8 | distributed under the License is distributed on an "AS IS" BASIS, 9 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | See the License for the specific language governing permissions and 11 | limitations under the License. 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Faraday Http Cache 2 | 3 | [![Gem Version](https://badge.fury.io/rb/faraday-http-cache.svg)](https://rubygems.org/gems/faraday-http-cache) 4 | [![Build](https://github.com/sourcelevel/faraday-http-cache/actions/workflows/main.yml/badge.svg)](https://github.com/sourcelevel/faraday-http-cache/actions) 5 | 6 | A [Faraday](https://github.com/lostisland/faraday) middleware that respects HTTP cache, 7 | by checking expiration and validation of the stored responses. 8 | 9 | ## Installation 10 | 11 | Add it to your Gemfile: 12 | 13 | ```ruby 14 | gem 'faraday-http-cache' 15 | ``` 16 | 17 | ## Usage and configuration 18 | 19 | You have to use the middleware in the Faraday instance that you want to, 20 | along with a suitable `store` to cache the responses. You can use the new 21 | shortcut using a symbol or passing the middleware class 22 | 23 | ```ruby 24 | client = Faraday.new do |builder| 25 | builder.use :http_cache, store: Rails.cache 26 | # or 27 | builder.use Faraday::HttpCache, store: Rails.cache 28 | 29 | builder.adapter Faraday.default_adapter 30 | end 31 | ``` 32 | 33 | The middleware accepts a `store` option for the cache backend responsible for recording 34 | the API responses that should be stored. Stores should respond to `write`, `read` and `delete`, 35 | just like an object from the `ActiveSupport::Cache` API. 36 | 37 | ```ruby 38 | # Connect the middleware to a Memcache instance. 39 | store = ActiveSupport::Cache.lookup_store(:mem_cache_store, ['localhost:11211']) 40 | 41 | client = Faraday.new do |builder| 42 | builder.use :http_cache, store: store 43 | builder.adapter Faraday.default_adapter 44 | end 45 | 46 | # Or use the Rails.cache instance inside your Rails app. 47 | client = Faraday.new do |builder| 48 | builder.use :http_cache, store: Rails.cache 49 | builder.adapter Faraday.default_adapter 50 | end 51 | ``` 52 | The default store provided is a simple in memory cache that lives on the client instance. 53 | This type of store **might not be persisted across multiple processes or connection instances** 54 | so it is probably not suitable for most production environments. 55 | Make sure that you configure a store that is suitable for you. 56 | 57 | The stdlib `JSON` module is used for serialization by default, which can struggle with unicode 58 | characters in responses in Ruby < 3.1. For example, if your JSON returns `"name": "Raül"` then 59 | you might see errors like: 60 | 61 | ``` 62 | Response could not be serialized: "\xC3" from ASCII-8BIT to UTF-8. Try using Marshal to serialize. 63 | ``` 64 | 65 | For full unicode support, or if you expect to be dealing with images, you can use the stdlib 66 | [Marshal][marshal] instead. Alternatively you could use another json library like `oj` or `yajl-ruby`. 67 | 68 | ```ruby 69 | client = Faraday.new do |builder| 70 | builder.use :http_cache, store: Rails.cache, serializer: Marshal 71 | builder.adapter Faraday.default_adapter 72 | end 73 | ``` 74 | 75 | ### Strategies 76 | 77 | You can provide a `:strategy` option to the middleware to specify the strategy to use. 78 | 79 | ```ruby 80 | client = Faraday.new do |builder| 81 | builder.use :http_cache, store: Rails.cache, strategy: Faraday::HttpCache::Strategies::ByVary 82 | builder.adapter Faraday.default_adapter 83 | end 84 | ``` 85 | 86 | Available strategies are: 87 | 88 | #### `Faraday::HttpCache::Strategies::ByUrl` 89 | 90 | The default strategy. 91 | It Uses URL + HTTP method to generate cache keys and stores an array of request + response for each key. 92 | 93 | #### `Faraday::HttpCache::Strategies::ByVary` 94 | 95 | This strategy uses headers from `Vary` header to generate cache keys. 96 | It also uses cache to store `Vary` headers mapped to the request URL. 97 | This strategy is more suitable for caching private responses with the same URLs but different results for different users, like `https://api.github.com/user`. 98 | 99 | *Note:* To automatically remove stale cache keys, you might want to use the `:expires_in` option. 100 | 101 | ```ruby 102 | store = ActiveSupport::Cache.lookup_store(:redis_cache_store, expires_in: 1.day, url: 'redis://localhost:6379/0') 103 | client = Faraday.new do |builder| 104 | builder.use :http_cache, store: store, strategy: Faraday::HttpCache::Strategies::ByVary 105 | builder.adapter Faraday.default_adapter 106 | end 107 | ``` 108 | 109 | #### Custom strategies 110 | 111 | You can write your own strategy by subclassing `Faraday::HttpCache::Strategies::BaseStrategy` and implementing `#write`, `#read` and `#delete` methods. 112 | 113 | ### Logging 114 | 115 | You can provide a `:logger` option that will receive debug information based on the middleware 116 | operations: 117 | 118 | ```ruby 119 | client = Faraday.new do |builder| 120 | builder.use :http_cache, store: Rails.cache, logger: Rails.logger 121 | builder.adapter Faraday.default_adapter 122 | end 123 | 124 | client.get('https://site/api/users') 125 | # logs "HTTP Cache: [GET users] miss, store" 126 | ``` 127 | 128 | ### Instrumentation 129 | 130 | In addition to logging you can instrument the middleware by passing in an `:instrumenter` option 131 | such as ActiveSupport::Notifications (compatible objects are also allowed). 132 | 133 | The event `http_cache.faraday` will be published every time the middleware 134 | processes a request. In the event payload, `:env` contains the response Faraday env and 135 | `:cache_status` contains a Symbol indicating the status of the cache processing for that request: 136 | 137 | - `:unacceptable` means that the request did not go through the cache at all. 138 | - `:miss` means that no cached response could be found. 139 | - `:invalid` means that the cached response could not be validated against the server. 140 | - `:valid` means that the cached response *could* be validated against the server. 141 | - `:fresh` means that the cached response was still fresh and could be returned without even 142 | calling the server. 143 | 144 | ```ruby 145 | client = Faraday.new do |builder| 146 | builder.use :http_cache, store: Rails.cache, instrumenter: ActiveSupport::Notifications 147 | builder.adapter Faraday.default_adapter 148 | end 149 | 150 | # Subscribes to all events from Faraday::HttpCache. 151 | ActiveSupport::Notifications.subscribe "http_cache.faraday" do |*args| 152 | event = ActiveSupport::Notifications::Event.new(*args) 153 | cache_status = event.payload[:cache_status] 154 | statsd = Statsd.new 155 | 156 | case cache_status 157 | when :fresh, :valid 158 | statsd.increment('api-calls.cache_hits') 159 | when :invalid, :miss 160 | statsd.increment('api-calls.cache_misses') 161 | when :unacceptable 162 | statsd.increment('api-calls.cache_bypass') 163 | end 164 | end 165 | ``` 166 | 167 | ## See it live 168 | 169 | You can clone this repository, install its dependencies with Bundler (run `bundle install`) and 170 | execute the files under the `examples` directory to see a sample of the middleware usage. 171 | 172 | ## What gets cached? 173 | 174 | The middleware will use the following headers to make caching decisions: 175 | - Vary 176 | - Cache-Control 177 | - Age 178 | - Last-Modified 179 | - ETag 180 | - Expires 181 | 182 | ### Cache-Control 183 | 184 | The `max-age`, `must-revalidate`, `proxy-revalidate` and `s-maxage` directives are checked. 185 | 186 | ### Shared vs. non-shared caches 187 | 188 | By default, the middleware acts as a "shared cache" per RFC 2616. This means it does not cache 189 | responses with `Cache-Control: private`. This behavior can be changed by passing in the 190 | `:shared_cache` configuration option: 191 | 192 | ```ruby 193 | client = Faraday.new do |builder| 194 | builder.use :http_cache, shared_cache: false 195 | builder.adapter Faraday.default_adapter 196 | end 197 | 198 | client.get('https://site/api/some-private-resource') # => will be cached 199 | ``` 200 | 201 | ## License 202 | 203 | Copyright (c) 2012-2018 Plataformatec. 204 | Copyright (c) 2019 SourceLevel and contributors. 205 | 206 | [marshal]: https://www.ruby-doc.org/core-3.0/Marshal.html 207 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env rake 2 | require 'bundler/gem_tasks' 3 | require 'rspec/core/rake_task' 4 | 5 | desc 'Run specs' 6 | RSpec::Core::RakeTask.new 7 | 8 | task default: :spec 9 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # 3 | # This file was generated by Bundler. 4 | # 5 | # The application 'rake' is installed as part of a gem, and 6 | # this file is here to facilitate running it. 7 | # 8 | 9 | require 'pathname' 10 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path("../../Gemfile", 11 | Pathname.new(__FILE__).realpath) 12 | 13 | require 'rubygems' 14 | require 'bundler/setup' 15 | 16 | load Gem.bin_path('rake', 'rake') 17 | -------------------------------------------------------------------------------- /bin/rspec: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # 3 | # This file was generated by Bundler. 4 | # 5 | # The application 'rspec' is installed as part of a gem, and 6 | # this file is here to facilitate running it. 7 | # 8 | 9 | require 'pathname' 10 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path("../../Gemfile", 11 | Pathname.new(__FILE__).realpath) 12 | 13 | require 'rubygems' 14 | require 'bundler/setup' 15 | 16 | load Gem.bin_path('rspec-core', 'rspec') 17 | -------------------------------------------------------------------------------- /examples/github.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'bundler/setup' 3 | 4 | require 'faraday/http_cache' 5 | require 'active_support' 6 | require 'active_support/logger' 7 | 8 | client = Faraday.new('https://api.github.com') do |stack| 9 | stack.use :http_cache, logger: ActiveSupport::Logger.new($stdout), strategy: Faraday::HttpCache::Strategies::ByVary 10 | stack.adapter Faraday.default_adapter 11 | end 12 | 13 | 5.times do |index| 14 | started = Time.now 15 | puts "Request ##{index + 1}" 16 | response = client.get('repos/sourcelevel/faraday-http-cache') 17 | finished = Time.now 18 | remaining = response.headers['X-RateLimit-Remaining'] 19 | limit = response.headers['X-RateLimit-Limit'] 20 | 21 | puts " Request took #{(finished - started) * 1000} ms." 22 | puts " Rate limits: remaining #{remaining} requests of #{limit}." 23 | end 24 | -------------------------------------------------------------------------------- /examples/parallel.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'bundler/setup' 3 | 4 | require 'faraday/http_cache' 5 | require 'faraday/em_http' 6 | require 'active_support' 7 | require 'active_support/logger' 8 | 9 | # To execute run: 10 | # FARADAY_ADAPTER=em_http FARADAY_VERSION='~> 1.0' bash -c 'bundle && bundle exec ruby examples/parallel.rb' 11 | client = Faraday.new('https://api.github.com') do |stack| 12 | stack.use :http_cache, logger: ActiveSupport::Logger.new($stdout) 13 | stack.adapter :em_http 14 | end 15 | 16 | 2.times do 17 | started = Time.now 18 | client.in_parallel do 19 | client.get('repos/sourcelevel/faraday-http-cache') 20 | client.get('repos/lostisland/faraday') 21 | end 22 | finished = Time.now 23 | 24 | puts " Parallel requests done! #{(finished - started) * 1000} ms." 25 | puts 26 | end 27 | -------------------------------------------------------------------------------- /faraday-http-cache.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Gem::Specification.new do |gem| 4 | gem.name = 'faraday-http-cache' 5 | gem.version = '2.5.1' 6 | gem.licenses = ['Apache-2.0'] 7 | gem.description = 'Middleware to handle HTTP caching' 8 | gem.summary = 'A Faraday middleware that stores and validates cache expiration.' 9 | gem.authors = ['Lucas Mazza', 'George Guimarães', 'Gustavo Araujo'] 10 | gem.email = ['opensource@sourcelevel.io'] 11 | gem.homepage = 'https://github.com/sourcelevel/faraday-http-cache' 12 | 13 | gem.files = Dir['LICENSE', 'README.md', 'lib/**/*'] 14 | gem.test_files = Dir['spec/**/*'] 15 | gem.require_paths = ['lib'] 16 | gem.executables = [] 17 | 18 | gem.required_ruby_version = '>= 2.4.0' 19 | gem.add_dependency 'faraday', '>= 0.8' 20 | end 21 | -------------------------------------------------------------------------------- /gemfiles/rubocop.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gem 'rubocop', '~> 1.12.0' 6 | -------------------------------------------------------------------------------- /lib/faraday-http-cache.rb: -------------------------------------------------------------------------------- 1 | require 'faraday/http_cache' 2 | -------------------------------------------------------------------------------- /lib/faraday/http_cache.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'faraday' 4 | 5 | require 'faraday/http_cache/storage' 6 | require 'faraday/http_cache/request' 7 | require 'faraday/http_cache/response' 8 | require 'faraday/http_cache/strategies' 9 | 10 | module Faraday 11 | # Public: The middleware responsible for caching and serving responses. 12 | # The middleware use the provided configuration options to establish on of 13 | # 'Faraday::HttpCache::Strategies' to cache responses retrieved by the stack 14 | # adapter. If a stored response can be served again for a subsequent 15 | # request, the middleware will return the response instead of issuing a new 16 | # request to it's server. This middleware should be the last attached handler 17 | # to your stack, so it will be closest to the inner app, avoiding issues 18 | # with other middlewares on your stack. 19 | # 20 | # Examples: 21 | # 22 | # # Using the middleware with a simple client: 23 | # client = Faraday.new do |builder| 24 | # builder.use :http_cache, store: my_store_backend 25 | # builder.adapter Faraday.default_adapter 26 | # end 27 | # 28 | # # Attach a Logger to the middleware. 29 | # client = Faraday.new do |builder| 30 | # builder.use :http_cache, logger: my_logger_instance, store: my_store_backend 31 | # builder.adapter Faraday.default_adapter 32 | # end 33 | # 34 | # # Provide an existing CacheStore (for instance, from a Rails app) 35 | # client = Faraday.new do |builder| 36 | # builder.use :http_cache, store: Rails.cache 37 | # end 38 | # 39 | # # Use Marshal for serialization 40 | # client = Faraday.new do |builder| 41 | # builder.use :http_cache, store: Rails.cache, serializer: Marshal 42 | # end 43 | # 44 | # # Instrument events using ActiveSupport::Notifications 45 | # client = Faraday.new do |builder| 46 | # builder.use :http_cache, store: Rails.cache, instrumenter: ActiveSupport::Notifications 47 | # end 48 | class HttpCache < Faraday::Middleware 49 | UNSAFE_METHODS = %i[post put delete patch].freeze 50 | 51 | ERROR_STATUSES = (400..499).freeze 52 | 53 | # The name of the instrumentation event. 54 | EVENT_NAME = 'http_cache.faraday' 55 | 56 | CACHE_STATUSES = [ 57 | # The request was not cacheable. 58 | :unacceptable, 59 | 60 | # The response was cached and can still be used. 61 | :fresh, 62 | 63 | # The response was cached and the server has validated it with a 304 response. 64 | :valid, 65 | 66 | # The response was cached but was not revalidated by the server. 67 | :invalid, 68 | 69 | # No response was found in the cache. 70 | :miss, 71 | 72 | # The response can't be cached. 73 | :uncacheable, 74 | 75 | # The request was cached but need to be revalidated by the server. 76 | :must_revalidate 77 | ].freeze 78 | 79 | # Public: Initializes a new HttpCache middleware. 80 | # 81 | # app - the next endpoint on the 'Faraday' stack. 82 | # :store - A cache store that should respond to 'read', 'write', and 'delete'. 83 | # :serializer - A serializer that should respond to 'dump' and 'load'. 84 | # :shared_cache - A flag to mark the middleware as a shared cache or not. 85 | # :instrumenter - An instrumentation object that should respond to 'instrument'. 86 | # :instrument_name - The String name of the instrument being reported on (optional). 87 | # :logger - A logger object. 88 | # 89 | # Examples: 90 | # 91 | # # Initialize the middleware with a logger. 92 | # Faraday::HttpCache.new(app, logger: my_logger) 93 | # 94 | # # Initialize the middleware with a logger and Marshal as a serializer 95 | # Faraday::HttpCache.new(app, logger: my_logger, serializer: Marshal) 96 | # 97 | # # Initialize the middleware with a FileStore at the 'tmp' dir. 98 | # store = ActiveSupport::Cache.lookup_store(:file_store, ['tmp']) 99 | # Faraday::HttpCache.new(app, store: store) 100 | # 101 | # # Initialize the middleware with a MemoryStore and logger 102 | # store = ActiveSupport::Cache.lookup_store 103 | # Faraday::HttpCache.new(app, store: store, logger: my_logger) 104 | def initialize(app, options = {}) 105 | super(app) 106 | 107 | options = options.dup 108 | @logger = options[:logger] 109 | @shared_cache = options.delete(:shared_cache) { true } 110 | @instrumenter = options.delete(:instrumenter) 111 | @instrument_name = options.delete(:instrument_name) { EVENT_NAME } 112 | 113 | strategy = options.delete(:strategy) { Strategies::ByUrl } 114 | 115 | @strategy = strategy.new(**options) 116 | end 117 | 118 | # Public: Process the request into a duplicate of this instance to 119 | # ensure that the internal state is preserved. 120 | def call(env) 121 | dup.call!(env) 122 | end 123 | 124 | # Internal: Process the stack request to try to serve a cache response. 125 | # On a cacheable request, the middleware will attempt to locate a 126 | # valid stored response to serve. On a cache miss, the middleware will 127 | # forward the request and try to store the response for future requests. 128 | # If the request can't be cached, the request will be delegated directly 129 | # to the underlying app and does nothing to the response. 130 | # The processed steps will be recorded to be logged once the whole 131 | # process is finished. 132 | # 133 | # Returns a 'Faraday::Response' instance. 134 | def call!(env) 135 | @trace = [] 136 | @request = create_request(env) 137 | 138 | response = nil 139 | 140 | if @request.cacheable? 141 | response = process(env) 142 | else 143 | trace :unacceptable 144 | response = @app.call(env) 145 | end 146 | 147 | response.on_complete do 148 | delete(@request, response) if should_delete?(response.status, @request.method) 149 | log_request 150 | response.env[:http_cache_trace] = @trace 151 | instrument(response.env) 152 | end 153 | end 154 | 155 | protected 156 | 157 | # Internal: Gets the request object created from the Faraday env Hash. 158 | attr_reader :request 159 | 160 | private 161 | 162 | # Internal: Should this cache instance act like a "shared cache" according 163 | # to the the definition in RFC 2616? 164 | def shared_cache? 165 | @shared_cache 166 | end 167 | 168 | # Internal: Checks if the current request method should remove any existing 169 | # cache entries for the same resource. 170 | # 171 | # Returns true or false. 172 | def should_delete?(status, method) 173 | UNSAFE_METHODS.include?(method) && !ERROR_STATUSES.cover?(status) 174 | end 175 | 176 | # Internal: Tries to locate a valid response or forwards the call to the stack. 177 | # * If no entry is present on the storage, the 'fetch' method will forward 178 | # the call to the remaining stack and return the new response. 179 | # * If a fresh response is found, the middleware will abort the remaining 180 | # stack calls and return the stored response back to the client. 181 | # * If a response is found but isn't fresh anymore, the middleware will 182 | # revalidate the response back to the server. 183 | # 184 | # env - the environment 'Hash' provided from the 'Faraday' stack. 185 | # 186 | # Returns the 'Faraday::Response' instance to be served. 187 | def process(env) 188 | entry = @strategy.read(@request) 189 | 190 | return fetch(env) if entry.nil? 191 | 192 | if entry.fresh? && !@request.no_cache? 193 | response = entry.to_response(env) 194 | trace :fresh 195 | else 196 | trace :must_revalidate 197 | response = validate(entry, env) 198 | end 199 | 200 | response 201 | end 202 | 203 | # Internal: Tries to validated a stored entry back to it's origin server 204 | # using the 'If-Modified-Since' and 'If-None-Match' headers with the 205 | # existing 'Last-Modified' and 'ETag' headers. If the new response 206 | # is marked as 'Not Modified', the previous stored response will be used 207 | # and forwarded against the Faraday stack. Otherwise, the freshly new 208 | # response will be stored (replacing the old one) and used. 209 | # 210 | # entry - a stale 'Faraday::HttpCache::Response' retrieved from the cache. 211 | # env - the environment 'Hash' to perform the request. 212 | # 213 | # Returns the 'Faraday::HttpCache::Response' to be forwarded into the stack. 214 | def validate(entry, env) 215 | headers = env[:request_headers] 216 | headers['If-Modified-Since'] = entry.last_modified if entry.last_modified 217 | headers['If-None-Match'] = entry.etag if entry.etag 218 | 219 | @app.call(env).on_complete do |requested_env| 220 | response = Response.new(requested_env) 221 | if response.not_modified? 222 | trace :valid 223 | updated_response_headers = response.payload[:response_headers] 224 | 225 | # These headers are not allowed in 304 responses, yet some proxy 226 | # servers add them in. Don't override the values from the original 227 | # response. 228 | updated_response_headers.delete('Content-Type') 229 | updated_response_headers.delete('Content-Length') 230 | 231 | updated_payload = entry.payload 232 | updated_payload[:response_headers].update(updated_response_headers) 233 | requested_env.update(updated_payload) 234 | response = Response.new(updated_payload) 235 | else 236 | trace :invalid 237 | end 238 | store(response) 239 | end 240 | end 241 | 242 | # Internal: Records a traced action to be used by the logger once the 243 | # request/response phase is finished. 244 | # 245 | # operation - the name of the performed action, a String or Symbol. 246 | # 247 | # Returns nothing. 248 | def trace(operation) 249 | @trace << operation 250 | end 251 | 252 | # Internal: Stores the response into the storage. 253 | # If the response isn't cacheable, a trace action 'uncacheable' will be 254 | # recorded for logging purposes. 255 | # 256 | # response - a 'Faraday::HttpCache::Response' instance to be stored. 257 | # 258 | # Returns nothing. 259 | def store(response) 260 | if shared_cache? ? response.cacheable_in_shared_cache? : response.cacheable_in_private_cache? 261 | trace :store 262 | @strategy.write(@request, response) 263 | else 264 | trace :uncacheable 265 | end 266 | end 267 | 268 | def delete(request, response) 269 | headers = %w[Location Content-Location] 270 | headers.each do |header| 271 | url = response.headers[header] 272 | @strategy.delete(url) if url 273 | end 274 | 275 | @strategy.delete(request.url) 276 | trace :delete 277 | end 278 | 279 | # Internal: Fetches the response from the Faraday stack and stores it. 280 | # 281 | # env - the environment 'Hash' from the Faraday stack. 282 | # 283 | # Returns the fresh 'Faraday::Response' instance. 284 | def fetch(env) 285 | trace :miss 286 | @app.call(env).on_complete do |fresh_env| 287 | response = Response.new(create_response(fresh_env)) 288 | store(response) 289 | end 290 | end 291 | 292 | # Internal: Creates a new 'Hash' containing the response information. 293 | # 294 | # env - the environment 'Hash' from the Faraday stack. 295 | # 296 | # Returns a 'Hash' containing the ':status', ':body' and 'response_headers' 297 | # entries. 298 | def create_response(env) 299 | hash = env.to_hash 300 | 301 | { 302 | status: hash[:status], 303 | body: hash[:body] || hash[:response_body], 304 | response_headers: hash[:response_headers], 305 | reason_phrase: hash[:reason_phrase] 306 | } 307 | end 308 | 309 | def create_request(env) 310 | Request.from_env(env) 311 | end 312 | 313 | # Internal: Logs the trace info about the incoming request 314 | # and how the middleware handled it. 315 | # This method does nothing if theresn't a logger present. 316 | # 317 | # Returns nothing. 318 | def log_request 319 | return unless @logger 320 | 321 | method = @request.method.to_s.upcase 322 | path = @request.url.request_uri 323 | @logger.debug { "HTTP Cache: [#{method} #{path}] #{@trace.join(', ')}" } 324 | end 325 | 326 | # Internal: instruments the request processing. 327 | # 328 | # Returns nothing. 329 | def instrument(env) 330 | return unless @instrumenter 331 | 332 | payload = { 333 | env: env, 334 | cache_status: extract_status(env[:http_cache_trace]) 335 | } 336 | 337 | @instrumenter.instrument(@instrument_name, payload) 338 | # DEPRECATED: Event name from the 1.1.1 release that isn't compatible 339 | # with the `ActiveSupport::LogSubscriber` API. 340 | @instrumenter.instrument('process_request.http_cache.faraday', payload) 341 | end 342 | 343 | # Internal: Extracts the cache status from a trace. 344 | # 345 | # Returns the Symbol status or nil if none was available. 346 | def extract_status(trace) 347 | CACHE_STATUSES.find { |status| trace.include?(status) } 348 | end 349 | end 350 | end 351 | 352 | if Faraday.respond_to?(:register_middleware) 353 | Faraday.register_middleware http_cache: Faraday::HttpCache 354 | elsif Faraday::Middleware.respond_to?(:register_middleware) 355 | Faraday::Middleware.register_middleware http_cache: Faraday::HttpCache 356 | end 357 | -------------------------------------------------------------------------------- /lib/faraday/http_cache/cache_control.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Faraday 4 | class HttpCache < Faraday::Middleware 5 | # Internal: A class to represent the 'Cache-Control' header options. 6 | # This implementation is based on 'rack-cache' internals by Ryan Tomayko. 7 | # It breaks the several directives into keys/values and stores them into 8 | # a Hash. 9 | class CacheControl 10 | # Internal: Initialize a new CacheControl. 11 | def initialize(header) 12 | @directives = parse(header.to_s) 13 | end 14 | 15 | # Internal: Checks if the 'public' directive is present. 16 | def public? 17 | @directives['public'] 18 | end 19 | 20 | # Internal: Checks if the 'private' directive is present. 21 | def private? 22 | @directives['private'] 23 | end 24 | 25 | # Internal: Checks if the 'no-cache' directive is present. 26 | def no_cache? 27 | @directives['no-cache'] 28 | end 29 | 30 | # Internal: Checks if the 'no-store' directive is present. 31 | def no_store? 32 | @directives['no-store'] 33 | end 34 | 35 | # Internal: Gets the 'max-age' directive as an Integer. 36 | # 37 | # Returns nil if the 'max-age' directive isn't present. 38 | def max_age 39 | @directives['max-age'].to_i if @directives.key?('max-age') 40 | end 41 | 42 | # Internal: Gets the 'max-age' directive as an Integer. 43 | # 44 | # takes the age header integer value and reduces the max-age and s-maxage 45 | # if present to account for having to remove static age header when caching responses 46 | def normalize_max_ages(age) 47 | if age > 0 48 | @directives['max-age'] = @directives['max-age'].to_i - age if @directives.key?('max-age') 49 | @directives['s-maxage'] = @directives['s-maxage'].to_i - age if @directives.key?('s-maxage') 50 | end 51 | end 52 | 53 | # Internal: Gets the 's-maxage' directive as an Integer. 54 | # 55 | # Returns nil if the 's-maxage' directive isn't present. 56 | def shared_max_age 57 | @directives['s-maxage'].to_i if @directives.key?('s-maxage') 58 | end 59 | alias s_maxage shared_max_age 60 | 61 | # Internal: Checks if the 'must-revalidate' directive is present. 62 | def must_revalidate? 63 | @directives['must-revalidate'] 64 | end 65 | 66 | # Internal: Checks if the 'proxy-revalidate' directive is present. 67 | def proxy_revalidate? 68 | @directives['proxy-revalidate'] 69 | end 70 | 71 | # Internal: Gets the String representation for the cache directives. 72 | # Directives are joined by a '=' and then combined into a single String 73 | # separated by commas. Directives with a 'true' value will omit the '=' 74 | # sign and their value. 75 | # 76 | # Returns the Cache Control string. 77 | def to_s 78 | booleans = [] 79 | values = [] 80 | 81 | @directives.each do |key, value| 82 | if value == true 83 | booleans << key 84 | elsif value 85 | values << "#{key}=#{value}" 86 | end 87 | end 88 | 89 | (booleans.sort + values.sort).join(', ') 90 | end 91 | 92 | private 93 | 94 | # Internal: Parses the Cache Control string to a Hash. 95 | # Existing whitespace will be removed and the string is split on commas. 96 | # For each part everything before a '=' will be treated as the key 97 | # and the exceeding will be treated as the value. If only the key is 98 | # present then the assigned value will default to true. 99 | # 100 | # Examples: 101 | # parse("max-age=600") 102 | # # => { "max-age" => "600"} 103 | # 104 | # parse("max-age") 105 | # # => { "max-age" => true } 106 | # 107 | # Returns a Hash. 108 | def parse(header) 109 | directives = {} 110 | 111 | header.delete(' ').split(',').each do |part| 112 | next if part.empty? 113 | 114 | name, value = part.split('=', 2) 115 | directives[name.downcase] = (value || true) unless name.empty? 116 | end 117 | 118 | directives 119 | end 120 | end 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /lib/faraday/http_cache/memory_store.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Faraday 4 | class HttpCache < Faraday::Middleware 5 | # @private 6 | # A Hash based store to be used by strategies 7 | # when a `store` is not provided for the middleware setup. 8 | class MemoryStore 9 | def initialize 10 | @cache = {} 11 | end 12 | 13 | def read(key) 14 | @cache[key] 15 | end 16 | 17 | def delete(key) 18 | @cache.delete(key) 19 | end 20 | 21 | def write(key, value) 22 | @cache[key] = value 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/faraday/http_cache/request.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Faraday 4 | class HttpCache < Faraday::Middleware 5 | # Internal: A class to represent a request 6 | class Request 7 | class << self 8 | def from_env(env) 9 | hash = env.to_hash 10 | new(method: hash[:method], url: hash[:url], headers: hash[:request_headers].dup) 11 | end 12 | end 13 | 14 | attr_reader :method, :url, :headers 15 | 16 | def initialize(method:, url:, headers:) 17 | @method = method 18 | @url = url 19 | @headers = headers 20 | end 21 | 22 | # Internal: Validates if the current request method is valid for caching. 23 | # 24 | # Returns true if the method is ':get' or ':head'. 25 | def cacheable? 26 | return false if method != :get && method != :head 27 | return false if cache_control.no_store? 28 | 29 | true 30 | end 31 | 32 | def no_cache? 33 | cache_control.no_cache? 34 | end 35 | 36 | # Internal: Gets the 'CacheControl' object. 37 | def cache_control 38 | @cache_control ||= CacheControl.new(headers['Cache-Control']) 39 | end 40 | 41 | def serializable_hash 42 | { 43 | method: @method, 44 | url: @url.to_s, 45 | headers: @headers 46 | } 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/faraday/http_cache/response.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'time' 4 | require 'faraday/http_cache/cache_control' 5 | 6 | module Faraday 7 | class HttpCache < Faraday::Middleware 8 | # Internal: A class to represent a response from a Faraday request. 9 | # It decorates the response hash into a smarter object that queries 10 | # the response headers and status informations about how the caching 11 | # middleware should handle this specific response. 12 | class Response 13 | # Internal: List of status codes that can be cached: 14 | # * 200 - 'OK' 15 | # * 203 - 'Non-Authoritative Information' 16 | # * 300 - 'Multiple Choices' 17 | # * 301 - 'Moved Permanently' 18 | # * 302 - 'Found' 19 | # * 404 - 'Not Found' 20 | # * 410 - 'Gone' 21 | CACHEABLE_STATUS_CODES = [200, 203, 300, 301, 302, 307, 404, 410].freeze 22 | 23 | # Internal: Gets the actual response Hash (status, headers and body). 24 | attr_reader :payload 25 | 26 | # Internal: Gets the 'Last-Modified' header from the headers Hash. 27 | attr_reader :last_modified 28 | 29 | # Internal: Gets the 'ETag' header from the headers Hash. 30 | attr_reader :etag 31 | 32 | # Internal: Initialize a new Response with the response payload from 33 | # a Faraday request. 34 | # 35 | # payload - the response Hash returned by a Faraday request. 36 | # :status - the status code from the response. 37 | # :response_headers - a 'Hash' like object with the headers. 38 | # :body - the response body. 39 | def initialize(payload = {}) 40 | @now = Time.now 41 | @payload = payload 42 | wrap_headers! 43 | ensure_date_header! 44 | 45 | @last_modified = headers['Last-Modified'] 46 | @etag = headers['ETag'] 47 | end 48 | 49 | # Internal: Checks the response freshness based on expiration headers. 50 | # The calculated 'ttl' should be present and bigger than 0. 51 | # 52 | # Returns true if the response is fresh, otherwise false. 53 | def fresh? 54 | !cache_control.no_cache? && ttl && ttl > 0 55 | end 56 | 57 | # Internal: Checks if the Response returned a 'Not Modified' status. 58 | # 59 | # Returns true if the response status code is 304. 60 | def not_modified? 61 | @payload[:status] == 304 62 | end 63 | 64 | # Internal: Checks if the response can be cached by the client when the 65 | # client is acting as a shared cache per RFC 2616. This is validated by 66 | # the 'Cache-Control' directives, the response status code and it's 67 | # freshness or validation status. 68 | # 69 | # Returns false if the 'Cache-Control' says that we can't store the 70 | # response, or it can be stored in private caches only, or if isn't fresh 71 | # or it can't be revalidated with the origin server. Otherwise, returns 72 | # true. 73 | def cacheable_in_shared_cache? 74 | cacheable?(true) 75 | end 76 | 77 | # Internal: Checks if the response can be cached by the client when the 78 | # client is acting as a private cache per RFC 2616. This is validated by 79 | # the 'Cache-Control' directives, the response status code and it's 80 | # freshness or validation status. 81 | # 82 | # Returns false if the 'Cache-Control' says that we can't store the 83 | # response, or if isn't fresh or it can't be revalidated with the origin 84 | # server. Otherwise, returns true. 85 | def cacheable_in_private_cache? 86 | cacheable?(false) 87 | end 88 | 89 | # Internal: Gets the response age in seconds. 90 | # 91 | # Returns the 'Age' header if present, or subtracts the response 'date' 92 | # from the current time. 93 | def age 94 | (headers['Age'] || (@now - date)).to_i 95 | end 96 | 97 | # Internal: Calculates the 'Time to live' left on the Response. 98 | # 99 | # Returns the remaining seconds for the response, or nil the 'max_age' 100 | # isn't present. 101 | def ttl 102 | max_age - age if max_age 103 | end 104 | 105 | # Internal: Parses the 'Date' header back into a Time instance. 106 | # 107 | # Returns the Time object. 108 | def date 109 | Time.httpdate(headers['Date']) 110 | end 111 | 112 | # Internal: Gets the response max age. 113 | # The max age is extracted from one of the following: 114 | # * The shared max age directive from the 'Cache-Control' header; 115 | # * The max age directive from the 'Cache-Control' header; 116 | # * The difference between the 'Expires' header and the response 117 | # date. 118 | # 119 | # Returns the max age value in seconds or nil if all options above fails. 120 | def max_age 121 | cache_control.shared_max_age || 122 | cache_control.max_age || 123 | (expires && (expires - @now)) 124 | end 125 | 126 | # Internal: Creates a new 'Faraday::Response', merging the stored 127 | # response with the supplied 'env' object. 128 | # 129 | # Returns a new instance of a 'Faraday::Response' with the payload. 130 | def to_response(env) 131 | env.update(@payload) 132 | Faraday::Response.new(env) 133 | end 134 | 135 | # Internal: Exposes a representation of the current 136 | # payload that we can serialize and cache properly. 137 | # 138 | # Returns a 'Hash'. 139 | def serializable_hash 140 | prepare_to_cache 141 | 142 | { 143 | status: @payload[:status], 144 | body: @payload[:body], 145 | response_headers: @payload[:response_headers], 146 | reason_phrase: @payload[:reason_phrase] 147 | } 148 | end 149 | 150 | private 151 | 152 | # Internal: Checks if this response can be revalidated. 153 | # 154 | # Returns true if the 'headers' contains a 'Last-Modified' or an 'ETag' 155 | # entry. 156 | def validateable? 157 | headers.key?('Last-Modified') || headers.key?('ETag') 158 | end 159 | 160 | # Internal: The logic behind cacheable_in_private_cache? and 161 | # cacheable_in_shared_cache? The logic is the same except for the 162 | # treatment of the private Cache-Control directive. 163 | def cacheable?(shared_cache) 164 | return false if (cache_control.private? && shared_cache) || cache_control.no_store? 165 | 166 | cacheable_status_code? && (validateable? || fresh?) 167 | end 168 | 169 | # Internal: Validates the response status against the 170 | # `CACHEABLE_STATUS_CODES' constant. 171 | # 172 | # Returns true if the constant includes the response status code. 173 | def cacheable_status_code? 174 | CACHEABLE_STATUS_CODES.include?(@payload[:status]) 175 | end 176 | 177 | # Internal: Gets the 'Expires' in a Time object. 178 | # 179 | # Returns the Time object, or nil if the header isn't present or isn't RFC 2616 compliant. 180 | def expires 181 | @expires ||= headers['Expires'] && Time.httpdate(headers['Expires']) rescue nil # rubocop:disable Style/RescueModifier 182 | end 183 | 184 | # Internal: Gets the 'CacheControl' object. 185 | def cache_control 186 | @cache_control ||= CacheControl.new(headers['Cache-Control']) 187 | end 188 | 189 | # Internal: Converts the headers 'Hash' into 'Faraday::Utils::Headers'. 190 | # Faraday actually uses a Hash subclass, `Faraday::Utils::Headers` to 191 | # store the headers hash. When retrieving a serialized response, 192 | # the headers object is decoded as a 'Hash' instead of the actual 193 | # 'Faraday::Utils::Headers' object, so we need to ensure that the 194 | # 'response_headers' is always a 'Headers' instead of a plain 'Hash'. 195 | # 196 | # Returns nothing. 197 | def wrap_headers! 198 | headers = @payload[:response_headers] 199 | 200 | @payload[:response_headers] = Faraday::Utils::Headers.new 201 | @payload[:response_headers].update(headers) if headers 202 | end 203 | 204 | # Internal: Try to parse the Date header, if it fails set it to @now. 205 | # 206 | # Returns nothing. 207 | def ensure_date_header! 208 | date 209 | rescue StandardError 210 | headers['Date'] = @now.httpdate 211 | end 212 | 213 | # Internal: Gets the headers 'Hash' from the payload. 214 | def headers 215 | @payload[:response_headers] 216 | end 217 | 218 | # Internal: Prepares the response headers to be cached. 219 | # 220 | # It removes the 'Age' header if present to allow cached responses 221 | # to continue aging while cached. It also normalizes the 'max-age' 222 | # related headers if the 'Age' header is provided to ensure accuracy 223 | # once the 'Age' header is removed. 224 | # 225 | # Returns nothing. 226 | def prepare_to_cache 227 | if headers.key? 'Age' 228 | cache_control.normalize_max_ages(headers['Age'].to_i) 229 | headers.delete 'Age' 230 | headers['Cache-Control'] = cache_control.to_s 231 | end 232 | end 233 | end 234 | end 235 | end 236 | -------------------------------------------------------------------------------- /lib/faraday/http_cache/storage.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'faraday/http_cache/strategies/by_url' 4 | 5 | module Faraday 6 | class HttpCache < Faraday::Middleware 7 | # @deprecated Use Faraday::HttpCache::Strategies::ByUrl instead. 8 | class Storage < Faraday::HttpCache::Strategies::ByUrl 9 | def initialize(*) 10 | Kernel.warn("Deprecated: #{self.class} is deprecated and will be removed in " \ 11 | 'the next major release. Use Faraday::HttpCache::Strategies::ByUrl instead.') 12 | super 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/faraday/http_cache/strategies.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'faraday/http_cache/strategies/by_url' 4 | require 'faraday/http_cache/strategies/by_vary' 5 | -------------------------------------------------------------------------------- /lib/faraday/http_cache/strategies/base_strategy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'json' 4 | require 'logger' 5 | require 'faraday/http_cache/memory_store' 6 | 7 | module Faraday 8 | class HttpCache < Faraday::Middleware 9 | module Strategies 10 | # Base class for all strategies. 11 | # @abstract 12 | # 13 | # @example 14 | # 15 | # # Creates a new strategy using a MemCached backend from ActiveSupport. 16 | # mem_cache_store = ActiveSupport::Cache.lookup_store(:mem_cache_store, ['localhost:11211']) 17 | # Faraday::HttpCache::Strategies::ByVary.new(store: mem_cache_store) 18 | # 19 | # # Reuse some other instance of an ActiveSupport::Cache::Store object. 20 | # Faraday::HttpCache::Strategies::ByVary.new(store: Rails.cache) 21 | # 22 | # # Creates a new strategy using Marshal for serialization. 23 | # Faraday::HttpCache::Strategies::ByVary.new(store: Rails.cache, serializer: Marshal) 24 | class BaseStrategy 25 | # Returns the underlying cache store object. 26 | attr_reader :cache 27 | 28 | # @param [Hash] options the options to create a message with. 29 | # @option options [Faraday::HttpCache::MemoryStore, nil] :store - a cache 30 | # store object that should respond to 'read', 'write', and 'delete'. 31 | # @option options [#dump#load] :serializer - an object that should 32 | # respond to 'dump' and 'load'. 33 | # @option options [Logger, nil] :logger - an object to be used to emit warnings. 34 | def initialize(options = {}) 35 | @cache = options[:store] || Faraday::HttpCache::MemoryStore.new 36 | @serializer = options[:serializer] || JSON 37 | @logger = options[:logger] || Logger.new(IO::NULL) 38 | @cache_salt = (@serializer.is_a?(Module) ? @serializer : @serializer.class).name 39 | assert_valid_store! 40 | end 41 | 42 | # Store a response inside the cache. 43 | # @abstract 44 | def write(_request, _response) 45 | raise NotImplementedError, 'Implement this method in your strategy' 46 | end 47 | 48 | # Read a response from the cache. 49 | # @abstract 50 | def read(_request) 51 | raise NotImplementedError, 'Implement this method in your strategy' 52 | end 53 | 54 | # Delete responses from the cache by the url. 55 | # @abstract 56 | def delete(_url) 57 | raise NotImplementedError, 'Implement this method in your strategy' 58 | end 59 | 60 | private 61 | 62 | # @private 63 | # @raise [ArgumentError] if the cache object doesn't support the expect API. 64 | def assert_valid_store! 65 | unless cache.respond_to?(:read) && cache.respond_to?(:write) && cache.respond_to?(:delete) 66 | raise ArgumentError.new("#{cache.inspect} is not a valid cache store as it does not responds to 'read', 'write' or 'delete'.") 67 | end 68 | end 69 | 70 | def serialize_entry(*objects) 71 | objects.map { |object| serialize_object(object) } 72 | end 73 | 74 | def serialize_object(object) 75 | @serializer.dump(object) 76 | end 77 | 78 | def deserialize_entry(*objects) 79 | objects.map { |object| deserialize_object(object) } 80 | end 81 | 82 | def deserialize_object(object) 83 | @serializer.load(object).each_with_object({}) do |(key, value), hash| 84 | hash[key.to_sym] = value 85 | end 86 | end 87 | 88 | def warn(message) 89 | @logger.warn(message) 90 | end 91 | end 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /lib/faraday/http_cache/strategies/by_url.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'digest/sha1' 4 | 5 | require 'faraday/http_cache/strategies/base_strategy' 6 | 7 | module Faraday 8 | class HttpCache < Faraday::Middleware 9 | module Strategies 10 | # The original strategy by Faraday::HttpCache. 11 | # Uses URL + HTTP method to generate cache keys. 12 | class ByUrl < BaseStrategy 13 | # Store a response inside the cache. 14 | # 15 | # @param [Faraday::HttpCache::Request] request - instance of the executed HTTP request. 16 | # @param [Faraday::HttpCache::Response] response - instance to be stored. 17 | # 18 | # @return [void] 19 | def write(request, response) 20 | key = cache_key_for(request.url) 21 | entry = serialize_entry(request.serializable_hash, response.serializable_hash) 22 | entries = cache.read(key) || [] 23 | entries = entries.dup if entries.frozen? 24 | entries.reject! do |(cached_request, cached_response)| 25 | response_matches?(request, deserialize_object(cached_request), deserialize_object(cached_response)) 26 | end 27 | 28 | entries << entry 29 | 30 | cache.write(key, entries) 31 | rescue ::Encoding::UndefinedConversionError => e 32 | warn "Response could not be serialized: #{e.message}. Try using Marshal to serialize." 33 | raise e 34 | end 35 | 36 | # Fetch a stored response that suits the incoming HTTP request or return nil. 37 | # 38 | # @param [Faraday::HttpCache::Request] request - an instance of the incoming HTTP request. 39 | # 40 | # @return [Faraday::HttpCache::Response, nil] 41 | def read(request) 42 | cache_key = cache_key_for(request.url) 43 | entries = cache.read(cache_key) 44 | response = lookup_response(request, entries) 45 | return nil unless response 46 | 47 | Faraday::HttpCache::Response.new(response) 48 | end 49 | 50 | # @param [String] url – the url of a changed resource, will be used to invalidate the cache. 51 | # 52 | # @return [void] 53 | def delete(url) 54 | cache_key = cache_key_for(url) 55 | cache.delete(cache_key) 56 | end 57 | 58 | private 59 | 60 | # Retrieve a response Hash from the list of entries that match the given request. 61 | # 62 | # @param [Faraday::HttpCache::Request] request - an instance of the incoming HTTP request. 63 | # @param [Array] entries - pairs of Hashes (request, response). 64 | # 65 | # @return [Hash, nil] 66 | def lookup_response(request, entries) 67 | if entries 68 | entries = entries.map { |entry| deserialize_entry(*entry) } 69 | _, response = entries.find { |req, res| response_matches?(request, req, res) } 70 | response 71 | end 72 | end 73 | 74 | # Check if a cached response and request matches the given request. 75 | # 76 | # @param [Faraday::HttpCache::Request] request - an instance of the incoming HTTP request. 77 | # @param [Hash] cached_request - a Hash of the request that was cached. 78 | # @param [Hash] cached_response - a Hash of the response that was cached. 79 | # 80 | # @return [true, false] 81 | def response_matches?(request, cached_request, cached_response) 82 | request.method.to_s == cached_request[:method].to_s && 83 | vary_matches?(cached_response, request, cached_request) 84 | end 85 | 86 | # Check if the cached request matches the incoming 87 | # request based on the Vary header of cached response. 88 | # 89 | # If Vary header is not present, the request is considered to match. 90 | # If Vary header is '*', the request is considered to not match. 91 | # 92 | # @param [Faraday::HttpCache::Request] request - an instance of the incoming HTTP request. 93 | # @param [Hash] cached_request - a Hash of the request that was cached. 94 | # @param [Hash] cached_response - a Hash of the response that was cached. 95 | # 96 | # @return [true, false] 97 | def vary_matches?(cached_response, request, cached_request) 98 | headers = Faraday::Utils::Headers.new(cached_response[:response_headers]) 99 | vary = headers['Vary'].to_s 100 | 101 | vary.empty? || (vary != '*' && vary.split(/[\s,]+/).all? do |header| 102 | request.headers[header] == (cached_request[:headers][header] || cached_request[:headers][header.downcase]) 103 | end) 104 | end 105 | 106 | # Computes the cache key for a specific request, taking 107 | # in account the current serializer to avoid cross serialization issues. 108 | # 109 | # @param [String] url - the request URL. 110 | # 111 | # @return [String] 112 | def cache_key_for(url) 113 | Digest::SHA1.hexdigest("#{@cache_salt}#{url}") 114 | end 115 | end 116 | end 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /lib/faraday/http_cache/strategies/by_vary.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'digest/sha1' 4 | 5 | require 'faraday/http_cache/strategies/base_strategy' 6 | 7 | module Faraday 8 | class HttpCache < Faraday::Middleware 9 | module Strategies 10 | # This strategy uses headers from the Vary response header to generate cache keys. 11 | # It also uses the index with Vary headers mapped to the request url. 12 | # This strategy is more suitable for caching private responses with the same urls, 13 | # like https://api.github.com/user. 14 | # 15 | # This strategy does not support #delete method to clear cache on unsafe methods. 16 | class ByVary < BaseStrategy 17 | # Store a response inside the cache. 18 | # 19 | # @param [Faraday::HttpCache::Request] request - instance of the executed HTTP request. 20 | # @param [Faraday::HttpCache::Response] response - instance to be stored. 21 | # 22 | # @return [void] 23 | def write(request, response) 24 | vary_cache_key = vary_cache_key_for(request) 25 | headers = Faraday::Utils::Headers.new(response.payload[:response_headers]) 26 | vary = headers['Vary'].to_s 27 | cache.write(vary_cache_key, vary) 28 | 29 | response_cache_key = response_cache_key_for(request, vary) 30 | entry = serialize_object(response.serializable_hash) 31 | cache.write(response_cache_key, entry) 32 | rescue ::Encoding::UndefinedConversionError => e 33 | warn "Response could not be serialized: #{e.message}. Try using Marshal to serialize." 34 | raise e 35 | end 36 | 37 | # Fetch a stored response that suits the incoming HTTP request or return nil. 38 | # 39 | # @param [Faraday::HttpCache::Request] request - an instance of the incoming HTTP request. 40 | # 41 | # @return [Faraday::HttpCache::Response, nil] 42 | def read(request) 43 | vary_cache_key = vary_cache_key_for(request) 44 | vary = cache.read(vary_cache_key) 45 | return nil if vary.nil? || vary == '*' 46 | 47 | cache_key = response_cache_key_for(request, vary) 48 | response = cache.read(cache_key) 49 | return nil if response.nil? 50 | 51 | Faraday::HttpCache::Response.new(deserialize_object(response)) 52 | end 53 | 54 | # This strategy does not support #delete method to clear cache on unsafe methods. 55 | # @return [void] 56 | def delete(_url) 57 | # do nothing since we can't find the key by url 58 | end 59 | 60 | private 61 | 62 | # Computes the cache key for the index with Vary headers. 63 | # 64 | # @param [Faraday::HttpCache::Request] request - instance of the executed HTTP request. 65 | # 66 | # @return [String] 67 | def vary_cache_key_for(request) 68 | method = request.method.to_s 69 | Digest::SHA1.hexdigest("by_vary_index#{@cache_salt}#{method}#{request.url}") 70 | end 71 | 72 | # Computes the cache key for the response. 73 | # 74 | # @param [Faraday::HttpCache::Request] request - instance of the executed HTTP request. 75 | # @param [String] vary - the Vary header value. 76 | # 77 | # @return [String] 78 | def response_cache_key_for(request, vary) 79 | method = request.method.to_s 80 | headers = vary.split(/[\s,]+/).uniq.sort.map { |header| request.headers[header] } 81 | Digest::SHA1.hexdigest("by_vary#{@cache_salt}#{method}#{request.url}#{headers.join}") 82 | end 83 | end 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /log/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sourcelevel/faraday-http-cache/f44ded4c9f6f794901c97eca1d82eb3ef2d5a5e6/log/.gitkeep -------------------------------------------------------------------------------- /spec/binary_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe Faraday::HttpCache do 6 | let(:client) do 7 | Faraday.new(url: ENV['FARADAY_SERVER']) do |stack| 8 | stack.use :http_cache, serializer: Marshal 9 | adapter = ENV['FARADAY_ADAPTER'] 10 | stack.headers['X-Faraday-Adapter'] = adapter 11 | stack.adapter adapter.to_sym 12 | end 13 | end 14 | let(:data) { IO.binread File.expand_path('support/empty.png', __dir__) } 15 | 16 | it 'works fine with binary data' do 17 | expect(client.get('image').body).to eq data 18 | expect(client.get('image').body).to eq data 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/cache_control_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe Faraday::HttpCache::CacheControl do 6 | it 'takes a String with multiple name=value pairs' do 7 | cache_control = Faraday::HttpCache::CacheControl.new('max-age=600, max-stale=300, min-fresh=570') 8 | expect(cache_control.max_age).to eq(600) 9 | end 10 | 11 | it 'takes a String with a single flag value' do 12 | cache_control = Faraday::HttpCache::CacheControl.new('no-cache') 13 | expect(cache_control).to be_no_cache 14 | end 15 | 16 | it 'takes a String with a bunch of all kinds of stuff' do 17 | cache_control = 18 | Faraday::HttpCache::CacheControl.new('max-age=600,must-revalidate,min-fresh=3000,foo=bar,baz') 19 | expect(cache_control.max_age).to eq(600) 20 | expect(cache_control).to be_must_revalidate 21 | end 22 | 23 | it 'strips leading and trailing spaces' do 24 | cache_control = Faraday::HttpCache::CacheControl.new(' public, max-age = 600 ') 25 | expect(cache_control).to be_public 26 | expect(cache_control.max_age).to eq(600) 27 | end 28 | 29 | it 'ignores blank segments' do 30 | cache_control = Faraday::HttpCache::CacheControl.new('max-age=600,,s-maxage=300') 31 | expect(cache_control.max_age).to eq(600) 32 | expect(cache_control.shared_max_age).to eq(300) 33 | end 34 | 35 | it 'sorts alphabetically with boolean directives before value directives' do 36 | cache_control = Faraday::HttpCache::CacheControl.new('foo=bar, z, x, y, bling=baz, zoom=zib, b, a') 37 | expect(cache_control.to_s).to eq('a, b, x, y, z, bling=baz, foo=bar, zoom=zib') 38 | end 39 | 40 | it 'responds to #max_age with an integer when max-age directive present' do 41 | cache_control = Faraday::HttpCache::CacheControl.new('public, max-age=600') 42 | expect(cache_control.max_age).to eq(600) 43 | end 44 | 45 | it 'responds to #max_age with nil when no max-age directive present' do 46 | cache_control = Faraday::HttpCache::CacheControl.new('public') 47 | expect(cache_control.max_age).to be_nil 48 | end 49 | 50 | it 'responds to #shared_max_age with an integer when s-maxage directive present' do 51 | cache_control = Faraday::HttpCache::CacheControl.new('public, s-maxage=600') 52 | expect(cache_control.shared_max_age).to eq(600) 53 | end 54 | 55 | it 'responds to #shared_max_age with nil when no s-maxage directive present' do 56 | cache_control = Faraday::HttpCache::CacheControl.new('public') 57 | expect(cache_control.shared_max_age).to be_nil 58 | end 59 | 60 | it 'responds to #public? truthfully when public directive present' do 61 | cache_control = Faraday::HttpCache::CacheControl.new('public') 62 | expect(cache_control).to be_public 63 | end 64 | 65 | it 'responds to #public? non-truthfully when no public directive present' do 66 | cache_control = Faraday::HttpCache::CacheControl.new('private') 67 | expect(cache_control).not_to be_public 68 | end 69 | 70 | it 'responds to #private? truthfully when private directive present' do 71 | cache_control = Faraday::HttpCache::CacheControl.new('private') 72 | expect(cache_control).to be_private 73 | end 74 | 75 | it 'responds to #private? non-truthfully when no private directive present' do 76 | cache_control = Faraday::HttpCache::CacheControl.new('public') 77 | expect(cache_control).not_to be_private 78 | end 79 | 80 | it 'responds to #no_cache? truthfully when no-cache directive present' do 81 | cache_control = Faraday::HttpCache::CacheControl.new('no-cache') 82 | expect(cache_control).to be_no_cache 83 | end 84 | 85 | it 'responds to #no_cache? non-truthfully when no no-cache directive present' do 86 | cache_control = Faraday::HttpCache::CacheControl.new('max-age=600') 87 | expect(cache_control).not_to be_no_cache 88 | end 89 | 90 | it 'responds to #must_revalidate? truthfully when must-revalidate directive present' do 91 | cache_control = Faraday::HttpCache::CacheControl.new('must-revalidate') 92 | expect(cache_control).to be_must_revalidate 93 | end 94 | 95 | it 'responds to #must_revalidate? non-truthfully when no must-revalidate directive present' do 96 | cache_control = Faraday::HttpCache::CacheControl.new('max-age=600') 97 | expect(cache_control).not_to be_must_revalidate 98 | end 99 | 100 | it 'responds to #proxy_revalidate? truthfully when proxy-revalidate directive present' do 101 | cache_control = Faraday::HttpCache::CacheControl.new('proxy-revalidate') 102 | expect(cache_control).to be_proxy_revalidate 103 | end 104 | 105 | it 'responds to #proxy_revalidate? non-truthfully when no proxy-revalidate directive present' do 106 | cache_control = Faraday::HttpCache::CacheControl.new('max-age=600') 107 | expect(cache_control).not_to be_no_cache 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /spec/http_cache_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe Faraday::HttpCache do 6 | let(:logger) { double('a Logger object', debug: nil, warn: nil) } 7 | let(:options) { { logger: logger } } 8 | 9 | let(:client) do 10 | Faraday.new(url: ENV['FARADAY_SERVER']) do |stack| 11 | stack.use Faraday::HttpCache, options 12 | adapter = ENV['FARADAY_ADAPTER'] 13 | stack.headers['X-Faraday-Adapter'] = adapter 14 | stack.headers['Content-Type'] = 'application/x-www-form-urlencoded' 15 | stack.adapter adapter.to_sym 16 | end 17 | end 18 | 19 | before do 20 | client.get('clear') 21 | end 22 | 23 | it 'does not cache POST requests' do 24 | client.post('post').body 25 | expect(client.post('post').body).to eq('2') 26 | end 27 | 28 | it 'logs that a POST request is unacceptable' do 29 | expect(logger).to receive(:debug) { |&block| expect(block.call).to eq('HTTP Cache: [POST /post] unacceptable, delete') } 30 | client.post('post').body 31 | end 32 | 33 | it 'does not cache responses with , status code' do 34 | client.get('broken') 35 | expect(client.get('broken').body).to eq('2') 36 | end 37 | 38 | it 'adds a trace of the actions performed to the env' do 39 | response = client.post('post') 40 | expect(response.env[:http_cache_trace]).to eq(%i[unacceptable delete]) 41 | end 42 | 43 | describe 'cache invalidation' do 44 | it 'expires POST requests' do 45 | client.get('counter') 46 | client.post('counter') 47 | expect(client.get('counter').body).to eq('2') 48 | end 49 | 50 | it 'logs that a POST request was deleted from the cache' do 51 | expect(logger).to receive(:debug) { |&block| expect(block.call).to eq('HTTP Cache: [POST /counter] unacceptable, delete') } 52 | client.post('counter') 53 | end 54 | 55 | it 'does not expires POST requests that failed' do 56 | client.get('get') 57 | client.post('get') 58 | expect(client.get('get').body).to eq('1') 59 | end 60 | 61 | it 'expires PUT requests' do 62 | client.get('counter') 63 | client.put('counter') 64 | expect(client.get('counter').body).to eq('2') 65 | end 66 | 67 | it 'logs that a PUT request was deleted from the cache' do 68 | expect(logger).to receive(:debug) { |&block| expect(block.call).to eq('HTTP Cache: [PUT /counter] unacceptable, delete') } 69 | client.put('counter') 70 | end 71 | 72 | it 'expires DELETE requests' do 73 | client.get('counter') 74 | client.delete('counter') 75 | expect(client.get('counter').body).to eq('2') 76 | end 77 | 78 | it 'logs that a DELETE request was deleted from the cache' do 79 | expect(logger).to receive(:debug) { |&block| expect(block.call).to eq('HTTP Cache: [DELETE /counter] unacceptable, delete') } 80 | client.delete('counter') 81 | end 82 | 83 | it 'expires PATCH requests' do 84 | client.get('counter') 85 | client.patch('counter') 86 | expect(client.get('counter').body).to eq('2') 87 | end 88 | 89 | it 'logs that a PATCH request was deleted from the cache' do 90 | expect(logger).to receive(:debug) { |&block| expect(block.call).to eq('HTTP Cache: [PATCH /counter] unacceptable, delete') } 91 | client.patch('counter') 92 | end 93 | 94 | it 'logs that a response with a bad status code is uncacheable' do 95 | expect(logger).to receive(:debug) { |&block| expect(block.call).to eq('HTTP Cache: [GET /broken] miss, uncacheable') } 96 | client.get('broken') 97 | end 98 | 99 | it 'expires entries for the "Location" header' do 100 | client.get('get') 101 | client.post('delete-with-location') 102 | expect(client.get('get').body).to eq('2') 103 | end 104 | 105 | it 'expires entries for the "Content-Location" header' do 106 | client.get('get') 107 | client.post('delete-with-content-location') 108 | expect(client.get('get').body).to eq('2') 109 | end 110 | end 111 | 112 | describe 'when acting as a shared cache' do 113 | let(:options) { { logger: logger, shared_cache: true } } 114 | 115 | it 'does not cache requests with a private cache control' do 116 | client.get('private') 117 | expect(client.get('private').body).to eq('2') 118 | end 119 | 120 | it 'logs that a private response is uncacheable' do 121 | expect(logger).to receive(:debug) { |&block| expect(block.call).to eq('HTTP Cache: [GET /private] miss, uncacheable') } 122 | client.get('private') 123 | end 124 | end 125 | 126 | describe 'when acting as a private cache' do 127 | let(:options) { { logger: logger, shared_cache: false } } 128 | 129 | it 'does cache requests with a private cache control' do 130 | client.get('private') 131 | expect(client.get('private').body).to eq('1') 132 | end 133 | 134 | it 'logs that a private response is stored' do 135 | expect(logger).to receive(:debug) { |&block| expect(block.call).to eq('HTTP Cache: [GET /private] miss, store') } 136 | client.get('private') 137 | end 138 | end 139 | 140 | it 'does not cache responses with a explicit no-store directive' do 141 | client.get('dontstore') 142 | expect(client.get('dontstore').body).to eq('2') 143 | end 144 | 145 | it 'logs that a response with a no-store directive is uncacheable' do 146 | expect(logger).to receive(:debug) { |&block| expect(block.call).to eq('HTTP Cache: [GET /dontstore] miss, uncacheable') } 147 | client.get('dontstore') 148 | end 149 | 150 | it 'does not caches multiple responses when the headers differ' do 151 | client.get('get', nil, 'HTTP_ACCEPT' => 'text/html') 152 | expect(client.get('get', nil, 'HTTP_ACCEPT' => 'text/html').body).to eq('1') 153 | expect(client.get('get', nil, 'HTTP_ACCEPT' => 'application/json').body).to eq('1') 154 | end 155 | 156 | it 'caches multiples responses based on the "Vary" header' do 157 | client.get('vary', nil, 'User-Agent' => 'Agent/1.0') 158 | expect(client.get('vary', nil, 'User-Agent' => 'Agent/1.0').body).to eq('1') 159 | expect(client.get('vary', nil, 'User-Agent' => 'Agent/2.0').body).to eq('2') 160 | expect(client.get('vary', nil, 'User-Agent' => 'Agent/3.0').body).to eq('3') 161 | end 162 | 163 | it 'never caches responses with the wildcard "Vary" header' do 164 | client.get('vary-wildcard') 165 | expect(client.get('vary-wildcard').body).to eq('2') 166 | end 167 | 168 | it 'caches requests with the "Expires" header' do 169 | client.get('expires') 170 | expect(client.get('expires').body).to eq('1') 171 | end 172 | 173 | it 'logs that a request with the "Expires" is fresh and stored' do 174 | expect(logger).to receive(:debug) { |&block| expect(block.call).to eq('HTTP Cache: [GET /expires] miss, store') } 175 | client.get('expires') 176 | end 177 | 178 | it 'caches GET responses' do 179 | client.get('get') 180 | expect(client.get('get').body).to eq('1') 181 | end 182 | 183 | context 'when the request has a "no-cache" directive' do 184 | it 'revalidates the cache' do 185 | expect(client.get('etag').body).to eq('1') 186 | expect(client.get('etag', nil, 'Cache-Control' => 'no-cache').body).to eq('1') 187 | 188 | expect(client.get('get', nil).body).to eq('2') 189 | expect(client.get('etag', nil, 'Cache-Control' => 'no-cache').body).to eq('3') 190 | end 191 | 192 | it 'caches the response' do 193 | client.get('get', nil, 'Cache-Control' => 'no-cache') 194 | expect(client.get('get', nil).body).to eq('1') 195 | end 196 | end 197 | 198 | context 'when the response has a "no-cache" directive' do 199 | it 'always revalidate the cached response' do 200 | client.get('no_cache') 201 | expect(client.get('no_cache').body).to eq('2') 202 | expect(client.get('no_cache').body).to eq('3') 203 | end 204 | end 205 | 206 | it 'logs that a GET response is stored' do 207 | expect(logger).to receive(:debug) { |&block| expect(block.call).to eq('HTTP Cache: [GET /get] miss, store') } 208 | client.get('get') 209 | end 210 | 211 | it 'differs requests with different query strings in the log' do 212 | expect(logger).to receive(:debug) { |&block| expect(block.call).to eq('HTTP Cache: [GET /get] miss, store') } 213 | expect(logger).to receive(:debug) { |&block| expect(block.call).to eq('HTTP Cache: [GET /get?q=what] miss, store') } 214 | client.get('get') 215 | client.get('get', q: 'what') 216 | end 217 | 218 | it 'logs that a stored GET response is fresh' do 219 | client.get('get') 220 | expect(logger).to receive(:debug) { |&block| expect(block.call).to eq('HTTP Cache: [GET /get] fresh') } 221 | client.get('get') 222 | end 223 | 224 | it 'sends the "Last-Modified" header on response validation' do 225 | client.get('timestamped') 226 | expect(client.get('timestamped').body).to eq('1') 227 | end 228 | 229 | it 'logs that the request with "Last-Modified" was revalidated' do 230 | client.get('timestamped') 231 | expect(logger).to receive(:debug) { |&block| expect(block.call).to eq('HTTP Cache: [GET /timestamped] must_revalidate, valid, store') } 232 | expect(client.get('timestamped').body).to eq('1') 233 | end 234 | 235 | it 'sends the "If-None-Match" header on response validation' do 236 | client.get('etag') 237 | expect(client.get('etag').body).to eq('1') 238 | end 239 | 240 | it 'logs that the request with "ETag" was revalidated' do 241 | client.get('etag') 242 | expect(logger).to receive(:debug) { |&block| expect(block.call).to eq('HTTP Cache: [GET /etag] must_revalidate, valid, store') } 243 | expect(client.get('etag').body).to eq('1') 244 | end 245 | 246 | it 'maintains the "Date" header for cached responses' do 247 | first_date = client.get('get').headers['Date'] 248 | second_date = client.get('get').headers['Date'] 249 | expect(first_date).to eq(second_date) 250 | end 251 | 252 | it 'preserves an old "Date" header if present' do 253 | date = client.get('yesterday').headers['Date'] 254 | expect(date).to match(/^\w{3}, \d{2} \w{3} \d{4} \d{2}:\d{2}:\d{2} GMT$/) 255 | end 256 | 257 | it 'updates the "Cache-Control" header when a response is validated' do 258 | first_cache_control = client.get('etag').headers['Cache-Control'] 259 | second_cache_control = client.get('etag').headers['Cache-Control'] 260 | expect(first_cache_control).not_to eql(second_cache_control) 261 | end 262 | 263 | it 'updates the "Date" header when a response is validated' do 264 | first_date = client.get('etag').headers['Date'] 265 | second_date = client.get('etag').headers['Date'] 266 | expect(first_date).not_to eql(second_date) 267 | end 268 | 269 | it 'updates the "Expires" header when a response is validated' do 270 | first_expires = client.get('etag').headers['Expires'] 271 | second_expires = client.get('etag').headers['Expires'] 272 | expect(first_expires).not_to eql(second_expires) 273 | end 274 | 275 | it 'updates the "Vary" header when a response is validated' do 276 | first_vary = client.get('etag').headers['Vary'] 277 | second_vary = client.get('etag').headers['Vary'] 278 | expect(first_vary).not_to eql(second_vary) 279 | end 280 | 281 | it 'caches non-stale response with "must-revalidate" directive' do 282 | client.get('must-revalidate') 283 | expect(client.get('must-revalidate').body).to eq('1') 284 | end 285 | 286 | describe 'Configuration options' do 287 | let(:app) { double('it is an app!') } 288 | 289 | it 'uses the options to create a Cache Store' do 290 | store = double(read: nil, write: nil) 291 | 292 | expect(Faraday::HttpCache::Strategies::ByUrl).to receive(:new).with(hash_including(store: store)) 293 | Faraday::HttpCache.new(app, store: store) 294 | end 295 | end 296 | end 297 | -------------------------------------------------------------------------------- /spec/instrumentation_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'active_support' 5 | require 'active_support/notifications' 6 | 7 | describe 'Instrumentation' do 8 | let(:backend) { Faraday::Adapter::Test::Stubs.new } 9 | 10 | let(:client) do 11 | Faraday.new do |stack| 12 | stack.use Faraday::HttpCache, instrumenter: ActiveSupport::Notifications 13 | stack.adapter :test, backend 14 | end 15 | end 16 | 17 | let(:events) { [] } 18 | let(:subscriber) { lambda { |*args| events << ActiveSupport::Notifications::Event.new(*args) } } 19 | 20 | around do |example| 21 | ActiveSupport::Notifications.subscribed(subscriber, 'http_cache.faraday') do 22 | example.run 23 | end 24 | end 25 | 26 | describe 'the :cache_status payload entry' do 27 | it 'is :miss if there is no cache entry for the URL' do 28 | backend.get('/hello') do 29 | [200, { 'Cache-Control' => 'public, max-age=999' }, ''] 30 | end 31 | 32 | client.get('/hello') 33 | expect(events.last.payload.fetch(:cache_status)).to eq(:miss) 34 | end 35 | 36 | it 'is :fresh if the cache entry has not expired' do 37 | backend.get('/hello') do 38 | [200, { 'Cache-Control' => 'public, max-age=999' }, ''] 39 | end 40 | 41 | client.get('/hello') # miss 42 | client.get('/hello') # fresh! 43 | expect(events.last.payload.fetch(:cache_status)).to eq(:fresh) 44 | end 45 | 46 | it 'is :valid if the cache entry can be validated against the upstream' do 47 | backend.get('/hello') do 48 | headers = { 49 | 'Cache-Control' => 'public, must-revalidate, max-age=0', 50 | 'Etag' => '123ABCD' 51 | } 52 | 53 | [200, headers, ''] 54 | end 55 | 56 | client.get('/hello') # miss 57 | 58 | backend.get('/hello') { [304, {}, ''] } 59 | 60 | client.get('/hello') # valid! 61 | expect(events.last.payload.fetch(:cache_status)).to eq(:valid) 62 | end 63 | 64 | it 'is :invalid if the cache entry could not be validated against the upstream' do 65 | backend.get('/hello') do 66 | headers = { 67 | 'Cache-Control' => 'public, must-revalidate, max-age=0', 68 | 'Etag' => '123ABCD' 69 | } 70 | 71 | [200, headers, ''] 72 | end 73 | 74 | client.get('/hello') # miss 75 | 76 | backend.get('/hello') { [200, {}, ''] } 77 | 78 | client.get('/hello') # invalid! 79 | expect(events.last.payload.fetch(:cache_status)).to eq(:invalid) 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /spec/json_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe Faraday::HttpCache do 6 | let(:client) do 7 | Faraday.new(url: ENV['FARADAY_SERVER']) do |stack| 8 | stack.response :json, content_type: /\bjson$/ 9 | stack.use :http_cache 10 | adapter = ENV['FARADAY_ADAPTER'] 11 | stack.headers['X-Faraday-Adapter'] = adapter 12 | stack.adapter adapter.to_sym 13 | end 14 | end 15 | 16 | it 'works fine with other middlewares' do 17 | client.get('clear') 18 | expect(client.get('json').body['count']).to eq(1) 19 | expect(client.get('json').body['count']).to eq(1) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/request_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe Faraday::HttpCache::Request do 6 | subject { Faraday::HttpCache::Request.new method: method, url: url, headers: headers } 7 | let(:method) { :get } 8 | let(:url) { URI.parse('http://example.com/path/to/somewhere') } 9 | let(:headers) { {} } 10 | 11 | context 'a GET request' do 12 | it { should be_cacheable } 13 | end 14 | 15 | context 'a HEAD request' do 16 | let(:method) { :head } 17 | it { should be_cacheable } 18 | end 19 | 20 | context 'a POST request' do 21 | let(:method) { :post } 22 | it { should_not be_cacheable } 23 | end 24 | 25 | context 'a PUT request' do 26 | let(:method) { :put } 27 | it { should_not be_cacheable } 28 | end 29 | 30 | context 'an OPTIONS request' do 31 | let(:method) { :options } 32 | it { should_not be_cacheable } 33 | end 34 | 35 | context 'a DELETE request' do 36 | let(:method) { :delete } 37 | it { should_not be_cacheable } 38 | end 39 | 40 | context 'a TRACE request' do 41 | let(:method) { :trace } 42 | it { should_not be_cacheable } 43 | end 44 | 45 | context 'with "Cache-Control: no-store"' do 46 | let(:headers) { { 'Cache-Control' => 'no-store' } } 47 | it { should_not be_cacheable } 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /spec/response_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe Faraday::HttpCache::Response do 6 | describe 'cacheable_in_shared_cache?' do 7 | it 'the response is not cacheable if the response is marked as private' do 8 | headers = { 'Cache-Control' => 'private, max-age=400' } 9 | response = Faraday::HttpCache::Response.new(status: 200, response_headers: headers) 10 | 11 | expect(response).not_to be_cacheable_in_shared_cache 12 | end 13 | 14 | it 'the response is not cacheable if it should not be stored' do 15 | headers = { 'Cache-Control' => 'no-store, max-age=400' } 16 | response = Faraday::HttpCache::Response.new(status: 200, response_headers: headers) 17 | 18 | expect(response).not_to be_cacheable_in_shared_cache 19 | end 20 | 21 | it 'the response is not cacheable when the status code is not acceptable' do 22 | headers = { 'Cache-Control' => 'max-age=400' } 23 | response = Faraday::HttpCache::Response.new(status: 503, response_headers: headers) 24 | expect(response).not_to be_cacheable_in_shared_cache 25 | end 26 | 27 | [200, 203, 300, 301, 302, 307, 404, 410].each do |status| 28 | it "the response is cacheable if the status code is #{status} and the response is fresh" do 29 | headers = { 'Cache-Control' => 'max-age=400' } 30 | response = Faraday::HttpCache::Response.new(status: status, response_headers: headers) 31 | 32 | expect(response).to be_cacheable_in_shared_cache 33 | end 34 | end 35 | end 36 | 37 | describe 'cacheable_in_private_cache?' do 38 | it 'the response is cacheable if the response is marked as private' do 39 | headers = { 'Cache-Control' => 'private, max-age=400' } 40 | response = Faraday::HttpCache::Response.new(status: 200, response_headers: headers) 41 | 42 | expect(response).to be_cacheable_in_private_cache 43 | end 44 | 45 | it 'the response is not cacheable if it should not be stored' do 46 | headers = { 'Cache-Control' => 'no-store, max-age=400' } 47 | response = Faraday::HttpCache::Response.new(status: 200, response_headers: headers) 48 | 49 | expect(response).not_to be_cacheable_in_private_cache 50 | end 51 | 52 | it 'the response is not cacheable when the status code is not acceptable' do 53 | headers = { 'Cache-Control' => 'max-age=400' } 54 | response = Faraday::HttpCache::Response.new(status: 503, response_headers: headers) 55 | expect(response).not_to be_cacheable_in_private_cache 56 | end 57 | 58 | [200, 203, 300, 301, 302, 307, 404, 410].each do |status| 59 | it "the response is cacheable if the status code is #{status} and the response is fresh" do 60 | headers = { 'Cache-Control' => 'max-age=400' } 61 | response = Faraday::HttpCache::Response.new(status: status, response_headers: headers) 62 | 63 | expect(response).to be_cacheable_in_private_cache 64 | end 65 | end 66 | end 67 | 68 | describe 'freshness' do 69 | it 'is fresh if the response still has some time to live' do 70 | date = (Time.now - 200).httpdate 71 | headers = { 'Cache-Control' => 'max-age=400', 'Date' => date } 72 | response = Faraday::HttpCache::Response.new(response_headers: headers) 73 | 74 | expect(response).to be_fresh 75 | end 76 | 77 | it 'is not fresh if the ttl has expired' do 78 | date = (Time.now - 500).httpdate 79 | headers = { 'Cache-Control' => 'max-age=400', 'Date' => date } 80 | response = Faraday::HttpCache::Response.new(response_headers: headers) 81 | 82 | expect(response).not_to be_fresh 83 | end 84 | 85 | it 'is not fresh if Cache Control has "no-cache"' do 86 | date = (Time.now - 200).httpdate 87 | headers = { 'Cache-Control' => 'max-age=400, no-cache', 'Date' => date } 88 | response = Faraday::HttpCache::Response.new(response_headers: headers) 89 | 90 | expect(response).not_to be_fresh 91 | end 92 | 93 | it 'is fresh if the response contains "must-revalidate" and is not stale' do 94 | date = (Time.now - 200).httpdate 95 | headers = { 'Cache-Control' => 'public, max-age=23880, must-revalidate, no-transform', 'Date' => date } 96 | response = Faraday::HttpCache::Response.new(response_headers: headers) 97 | 98 | expect(response).to be_fresh 99 | end 100 | 101 | it 'is not fresh if Cache Control has "must-revalidate" and is stale' do 102 | date = (Time.now - 500).httpdate 103 | headers = { 'Cache-Control' => 'max-age=400, must-revalidate', 'Date' => date } 104 | response = Faraday::HttpCache::Response.new(response_headers: headers) 105 | 106 | expect(response).not_to be_fresh 107 | end 108 | end 109 | 110 | it 'sets the "Date" header if is not present' do 111 | headers = { 'Date' => nil } 112 | response = Faraday::HttpCache::Response.new(response_headers: headers) 113 | 114 | expect(response.date).to be 115 | end 116 | 117 | it 'sets the "Date" header if is not a valid RFC 2616 compliant string' do 118 | date = Time.now.httpdate 119 | headers = { 'Date' => "#{date}, #{date}" } 120 | response = Faraday::HttpCache::Response.new(response_headers: headers) 121 | 122 | expect(response.date).to be 123 | end 124 | 125 | it 'the response is not modified if the status code is 304' do 126 | response = Faraday::HttpCache::Response.new(status: 304) 127 | expect(response).to be_not_modified 128 | end 129 | 130 | it 'returns the "Last-Modified" header on the #last_modified method' do 131 | headers = { 'Last-Modified' => '123' } 132 | response = Faraday::HttpCache::Response.new(response_headers: headers) 133 | expect(response.last_modified).to eq('123') 134 | end 135 | 136 | it 'returns the "ETag" header on the #etag method' do 137 | headers = { 'ETag' => 'tag' } 138 | response = Faraday::HttpCache::Response.new(response_headers: headers) 139 | expect(response.etag).to eq('tag') 140 | end 141 | 142 | describe 'max age calculation' do 143 | it 'uses the shared max age directive when present' do 144 | headers = { 'Cache-Control' => 's-maxage=200, max-age=0' } 145 | response = Faraday::HttpCache::Response.new(response_headers: headers) 146 | expect(response.max_age).to be(200) 147 | end 148 | 149 | it 'uses the max age directive when present' do 150 | headers = { 'Cache-Control' => 'max-age=200' } 151 | response = Faraday::HttpCache::Response.new(response_headers: headers) 152 | expect(response.max_age).to be(200) 153 | end 154 | 155 | it 'fallsback to the expiration date leftovers' do 156 | headers = { 'Expires' => (Time.now + 100).httpdate, 'Date' => Time.now.httpdate } 157 | response = Faraday::HttpCache::Response.new(response_headers: headers) 158 | 159 | expect(response.max_age).to be < 100 160 | expect(response.max_age).to be > 98 161 | end 162 | 163 | it 'returns nil when there is no information to calculate the max age' do 164 | response = Faraday::HttpCache::Response.new 165 | expect(response.max_age).to be_nil 166 | end 167 | 168 | it 'returns nil when falling back to expiration date but it is not RFC 2616 compliant' do 169 | headers = { 'Expires' => 'Mon, 1 Jan 2001 00:00:00 GMT' } 170 | response = Faraday::HttpCache::Response.new(response_headers: headers) 171 | expect(response.max_age).to be_nil 172 | end 173 | end 174 | 175 | describe 'age calculation' do 176 | it 'uses the "Age" header if it is present' do 177 | response = Faraday::HttpCache::Response.new(response_headers: { 'Age' => '3' }) 178 | expect(response.age).to eq(3) 179 | end 180 | 181 | it 'calculates the time from the "Date" header' do 182 | date = (Time.now - 3).httpdate 183 | response = Faraday::HttpCache::Response.new(response_headers: { 'Date' => date }) 184 | expect(response.age).to eq(3) 185 | end 186 | 187 | it 'returns 0 if there is no "Age" or "Date" header present' do 188 | response = Faraday::HttpCache::Response.new(response_headers: {}) 189 | expect(response.age).to eq(0) 190 | end 191 | end 192 | 193 | describe 'time to live calculation' do 194 | it 'returns the time to live based on the max age limit' do 195 | date = (Time.now - 200).httpdate 196 | headers = { 'Cache-Control' => 'max-age=400', 'Date' => date } 197 | response = Faraday::HttpCache::Response.new(response_headers: headers) 198 | expect(response.ttl).to eq(200) 199 | end 200 | end 201 | 202 | describe 'response unboxing' do 203 | subject { described_class.new(status: 200, response_headers: {}, body: 'Hi!', reason_phrase: 'Success') } 204 | 205 | let(:env) { { method: :get } } 206 | let(:response) { subject.to_response(env) } 207 | 208 | it 'merges the supplied env object with the response data' do 209 | expect(response.env[:method]).to be 210 | end 211 | 212 | it 'returns a Faraday::Response' do 213 | expect(response).to be_a(Faraday::Response) 214 | end 215 | 216 | it 'merges the status code' do 217 | expect(response.status).to eq(200) 218 | end 219 | 220 | it 'merges the headers' do 221 | expect(response.headers).to be_a(Faraday::Utils::Headers) 222 | end 223 | 224 | it 'merges the body' do 225 | expect(response.body).to eq('Hi!') 226 | end 227 | 228 | it 'merges the reason phrase' do 229 | expect(response.reason_phrase).to eq('Success') if response.respond_to?(:reason_phrase) 230 | end 231 | end 232 | 233 | describe 'remove age before caching and normalize max-age if non-zero age present' do 234 | it 'is fresh if the response still has some time to live' do 235 | headers = { 236 | 'Age' => 6, 237 | 'Cache-Control' => 'public, max-age=40', 238 | 'Date' => (Time.now - 38).httpdate, 239 | 'Expires' => (Time.now - 37).httpdate, 240 | 'Last-Modified' => (Time.now - 300).httpdate 241 | } 242 | response = Faraday::HttpCache::Response.new(response_headers: headers) 243 | expect(response).to be_fresh 244 | 245 | response.serializable_hash 246 | expect(response.max_age).to eq(34) 247 | expect(response).not_to be_fresh 248 | end 249 | end 250 | end 251 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'uri' 4 | require 'socket' 5 | 6 | require 'faraday-http-cache' 7 | 8 | if Gem::Version.new(Faraday::VERSION) < Gem::Version.new('1.0') 9 | require 'faraday_middleware' 10 | elsif ENV['FARADAY_ADAPTER'] == 'em_http' 11 | require 'faraday/em_http' 12 | end 13 | 14 | require 'active_support' 15 | require 'active_support/cache' 16 | 17 | require 'support/test_app' 18 | require 'support/test_server' 19 | 20 | server = TestServer.new 21 | 22 | ENV['FARADAY_SERVER'] = server.endpoint 23 | ENV['FARADAY_ADAPTER'] ||= 'net_http' 24 | 25 | server.start 26 | 27 | RSpec.configure do |config| 28 | config.run_all_when_everything_filtered = true 29 | config.order = 'random' 30 | 31 | config.after(:suite) do 32 | server.stop 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/storage_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe Faraday::HttpCache::Storage do 6 | let(:cache_key) { '6e3b941d0f7572291c777b3e48c04b74124a55d0' } 7 | let(:request) do 8 | env = { method: :get, url: 'http://test/index' } 9 | double(env.merge(serializable_hash: env)) 10 | end 11 | 12 | let(:response) { double(serializable_hash: { response_headers: {} }) } 13 | 14 | let(:cache) { Faraday::HttpCache::MemoryStore.new } 15 | 16 | let(:storage) { Faraday::HttpCache::Storage.new(store: cache) } 17 | subject { storage } 18 | 19 | before do 20 | allow(Kernel).to receive(:warn).with( 21 | 'Deprecated: Faraday::HttpCache::Storage is deprecated and will be removed '\ 22 | 'in the next major release. Use Faraday::HttpCache::Strategies::ByUrl instead.' 23 | ) 24 | end 25 | 26 | it 'creates strategy and warns about deprecation' do 27 | expect(Kernel).to receive(:warn).with( 28 | 'Deprecated: Faraday::HttpCache::Storage is deprecated and will be removed '\ 29 | 'in the next major release. Use Faraday::HttpCache::Strategies::ByUrl instead.' 30 | ) 31 | is_expected.to be_a_kind_of(Faraday::HttpCache::Strategies::ByUrl) 32 | end 33 | 34 | describe 'Cache configuration' do 35 | it 'uses a MemoryStore by default' do 36 | expect(Faraday::HttpCache::MemoryStore).to receive(:new).and_call_original 37 | Faraday::HttpCache::Storage.new 38 | end 39 | 40 | it 'raises an error when the given store is not valid' do 41 | wrong = double 42 | 43 | expect { 44 | Faraday::HttpCache::Storage.new(store: wrong) 45 | }.to raise_error(ArgumentError) 46 | end 47 | end 48 | 49 | describe 'storing responses' do 50 | shared_examples 'A storage with serialization' do 51 | it 'writes the response object to the underlying cache' do 52 | entry = [serializer.dump(request.serializable_hash), serializer.dump(response.serializable_hash)] 53 | expect(cache).to receive(:write).with(cache_key, [entry]) 54 | subject.write(request, response) 55 | end 56 | end 57 | 58 | context 'with the JSON serializer' do 59 | let(:serializer) { JSON } 60 | it_behaves_like 'A storage with serialization' 61 | 62 | context 'when ASCII characters in response cannot be converted to UTF-8', if: Gem::Version.new(RUBY_VERSION) < Gem::Version.new('3.1') do 63 | let(:response) do 64 | body = String.new("\u2665").force_encoding('ASCII-8BIT') 65 | double(:response, serializable_hash: { 'body' => body }) 66 | end 67 | 68 | it 'raises and logs a warning' do 69 | logger = double(:logger, warn: nil) 70 | storage = Faraday::HttpCache::Storage.new(logger: logger) 71 | 72 | expect { 73 | storage.write(request, response) 74 | }.to raise_error(::Encoding::UndefinedConversionError) 75 | expect(logger).to have_received(:warn).with( 76 | 'Response could not be serialized: "\xE2" from ASCII-8BIT to UTF-8. Try using Marshal to serialize.' 77 | ) 78 | end 79 | end 80 | end 81 | 82 | context 'with the Marshal serializer' do 83 | let(:cache_key) { '337d1e9c6c92423dd1c48a23054139058f97be40' } 84 | let(:serializer) { Marshal } 85 | let(:storage) { Faraday::HttpCache::Storage.new(store: cache, serializer: Marshal) } 86 | 87 | it_behaves_like 'A storage with serialization' 88 | end 89 | end 90 | 91 | describe 'reading responses' do 92 | let(:storage) { Faraday::HttpCache::Storage.new(store: cache, serializer: serializer) } 93 | 94 | shared_examples 'A storage with serialization' do 95 | it 'returns nil if the response is not cached' do 96 | expect(subject.read(request)).to be_nil 97 | end 98 | 99 | it 'decodes a stored response' do 100 | subject.write(request, response) 101 | 102 | expect(subject.read(request)).to be_a(Faraday::HttpCache::Response) 103 | end 104 | end 105 | 106 | context 'with the JSON serializer' do 107 | let(:serializer) { JSON } 108 | 109 | it_behaves_like 'A storage with serialization' 110 | end 111 | 112 | context 'with the Marshal serializer' do 113 | let(:serializer) { Marshal } 114 | 115 | it_behaves_like 'A storage with serialization' 116 | end 117 | end 118 | 119 | describe 'deleting responses' do 120 | it 'removes the entries from the cache of the given URL' do 121 | subject.write(request, response) 122 | subject.delete(request.url) 123 | expect(subject.read(request)).to be_nil 124 | end 125 | end 126 | 127 | describe 'remove age before caching and normalize max-age if non-zero age present' do 128 | it 'is fresh if the response still has some time to live' do 129 | headers = { 130 | 'Age' => 6, 131 | 'Cache-Control' => 'public, max-age=40', 132 | 'Date' => (Time.now - 38).httpdate, 133 | 'Expires' => (Time.now - 37).httpdate, 134 | 'Last-Modified' => (Time.now - 300).httpdate 135 | } 136 | response = Faraday::HttpCache::Response.new(response_headers: headers) 137 | expect(response).to be_fresh 138 | subject.write(request, response) 139 | 140 | cached_response = subject.read(request) 141 | expect(cached_response.max_age).to eq(34) 142 | expect(cached_response).not_to be_fresh 143 | end 144 | 145 | it 'is fresh until cached and that 1 second elapses then the response is no longer fresh' do 146 | current_time = Time.now 147 | headers = { 148 | 'Date' => (current_time - 39).httpdate, 149 | 'Expires' => (current_time + 40).httpdate 150 | } 151 | 152 | response = Faraday::HttpCache::Response.new(response_headers: headers) 153 | expect(response).to be_fresh 154 | subject.write(request, response) 155 | 156 | sleep(1) 157 | cached_response = subject.read(request) 158 | expect(cached_response).not_to be_fresh 159 | end 160 | end 161 | end 162 | -------------------------------------------------------------------------------- /spec/strategies/base_strategy_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe Faraday::HttpCache::Strategies::BaseStrategy do 6 | subject(:strategy) { described_class.new } 7 | 8 | it 'uses a MemoryStore as a default store' do 9 | expect(Faraday::HttpCache::MemoryStore).to receive(:new).and_call_original 10 | strategy 11 | end 12 | 13 | context 'when the given store is not valid' do 14 | let(:store) { double(:wrong_store) } 15 | subject(:strategy) { described_class.new(store: store) } 16 | 17 | it 'raises an error' do 18 | expect { strategy }.to raise_error(ArgumentError) 19 | end 20 | end 21 | 22 | it 'raises an error when abstract methods are called' do 23 | expect { strategy.write(nil, nil) }.to raise_error(NotImplementedError) 24 | expect { strategy.read(nil) }.to raise_error(NotImplementedError) 25 | expect { strategy.delete(nil) }.to raise_error(NotImplementedError) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/strategies/by_url_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe Faraday::HttpCache::Strategies::ByUrl do 6 | let(:cache_key) { '6e3b941d0f7572291c777b3e48c04b74124a55d0' } 7 | let(:request) do 8 | env = { method: :get, url: 'http://test/index' } 9 | double(env.merge(serializable_hash: env)) 10 | end 11 | 12 | let(:response) { double(serializable_hash: { response_headers: {} }) } 13 | 14 | let(:cache) { Faraday::HttpCache::MemoryStore.new } 15 | 16 | let(:strategy) { described_class.new(store: cache) } 17 | subject { strategy } 18 | 19 | describe 'Cache configuration' do 20 | it 'uses a MemoryStore by default' do 21 | expect(Faraday::HttpCache::MemoryStore).to receive(:new).and_call_original 22 | described_class.new 23 | end 24 | 25 | it 'raises an error when the given store is not valid' do 26 | wrong = double 27 | 28 | expect { 29 | described_class.new(store: wrong) 30 | }.to raise_error(ArgumentError) 31 | end 32 | end 33 | 34 | describe 'storing responses' do 35 | shared_examples 'A strategy with serialization' do 36 | it 'writes the response object to the underlying cache' do 37 | entry = [serializer.dump(request.serializable_hash), serializer.dump(response.serializable_hash)] 38 | expect(cache).to receive(:write).with(cache_key, [entry]) 39 | subject.write(request, response) 40 | end 41 | end 42 | 43 | context 'with the JSON serializer' do 44 | let(:serializer) { JSON } 45 | it_behaves_like 'A strategy with serialization' 46 | 47 | context 'when ASCII characters in response cannot be converted to UTF-8', if: Gem::Version.new(RUBY_VERSION) < Gem::Version.new('3.1') do 48 | let(:response) do 49 | body = String.new("\u2665").force_encoding('ASCII-8BIT') 50 | double(:response, serializable_hash: { 'body' => body }) 51 | end 52 | 53 | it 'raises and logs a warning' do 54 | logger = double(:logger, warn: nil) 55 | strategy = described_class.new(logger: logger) 56 | 57 | expect { 58 | strategy.write(request, response) 59 | }.to raise_error(::Encoding::UndefinedConversionError) 60 | expect(logger).to have_received(:warn).with( 61 | 'Response could not be serialized: "\xE2" from ASCII-8BIT to UTF-8. Try using Marshal to serialize.' 62 | ) 63 | end 64 | end 65 | end 66 | 67 | context 'with the Marshal serializer' do 68 | let(:cache_key) { '337d1e9c6c92423dd1c48a23054139058f97be40' } 69 | let(:serializer) { Marshal } 70 | let(:strategy) { described_class.new(store: cache, serializer: Marshal) } 71 | 72 | it_behaves_like 'A strategy with serialization' 73 | end 74 | end 75 | 76 | describe 'reading responses' do 77 | let(:strategy) { described_class.new(store: cache, serializer: serializer) } 78 | 79 | shared_examples 'A strategy with serialization' do 80 | it 'returns nil if the response is not cached' do 81 | expect(subject.read(request)).to be_nil 82 | end 83 | 84 | it 'decodes a stored response' do 85 | subject.write(request, response) 86 | 87 | expect(subject.read(request)).to be_a(Faraday::HttpCache::Response) 88 | end 89 | 90 | context 'with a Vary header in the response in a different case than the matching request header' do 91 | let(:request) do 92 | Faraday::HttpCache::Request.new( 93 | method: :get, 94 | url: 'http://test/index', 95 | headers: Faraday::Utils::Headers.new({ 'accept' => 'application/json' }) 96 | ) 97 | end 98 | let(:response) do 99 | Faraday::HttpCache::Response.new(response_headers: Faraday::Utils::Headers.new({ vary: 'Accept' })) 100 | end 101 | 102 | it 'reads stored message' do 103 | subject.write(request, response) 104 | expect(subject.read(request)).to be_a(Faraday::HttpCache::Response) 105 | end 106 | end 107 | end 108 | 109 | context 'with the JSON serializer' do 110 | let(:serializer) { JSON } 111 | 112 | it_behaves_like 'A strategy with serialization' 113 | end 114 | 115 | context 'with the Marshal serializer' do 116 | let(:serializer) { Marshal } 117 | 118 | it_behaves_like 'A strategy with serialization' 119 | end 120 | end 121 | 122 | describe 'deleting responses' do 123 | it 'removes the entries from the cache of the given URL' do 124 | subject.write(request, response) 125 | subject.delete(request.url) 126 | expect(subject.read(request)).to be_nil 127 | end 128 | end 129 | 130 | describe 'remove age before caching and normalize max-age if non-zero age present' do 131 | it 'is fresh if the response still has some time to live' do 132 | headers = { 133 | 'Age' => 6, 134 | 'Cache-Control' => 'public, max-age=40', 135 | 'Date' => (Time.now - 38).httpdate, 136 | 'Expires' => (Time.now - 37).httpdate, 137 | 'Last-Modified' => (Time.now - 300).httpdate 138 | } 139 | response = Faraday::HttpCache::Response.new(response_headers: headers) 140 | expect(response).to be_fresh 141 | subject.write(request, response) 142 | 143 | cached_response = subject.read(request) 144 | expect(cached_response.max_age).to eq(34) 145 | expect(cached_response).not_to be_fresh 146 | end 147 | 148 | it 'is fresh until cached and that 1 second elapses then the response is no longer fresh' do 149 | headers = { 150 | 'Date' => (Time.now - 39).httpdate, 151 | 'Expires' => (Time.now + 40).httpdate 152 | } 153 | 154 | response = Faraday::HttpCache::Response.new(response_headers: headers) 155 | expect(response).to be_fresh 156 | subject.write(request, response) 157 | 158 | sleep(1) 159 | cached_response = subject.read(request) 160 | expect(cached_response).not_to be_fresh 161 | end 162 | end 163 | end 164 | -------------------------------------------------------------------------------- /spec/strategies/by_vary_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe Faraday::HttpCache::Strategies::ByVary do 6 | let(:vary_index_cache_key) { '64896419583e8022efeb21d0ece6e266c0e58b59' } 7 | let(:cache_key) { '978047698d156fe8642a86dbfaacc675917c9a22' } 8 | let(:vary) { 'Accept, Accept-Encoding, X-Requested-With' } 9 | let(:headers) { {'Accept' => 'text/html', 'Accept-Encoding' => 'gzip, deflate, br' } } 10 | let(:request) do 11 | env = {method: :get, url: 'http://test/index', headers: headers} 12 | double(env.merge(serializable_hash: env)) 13 | end 14 | 15 | let(:response_payload) { {response_headers: {'Vary' => vary}} } 16 | 17 | let(:response) do 18 | instance_double(Faraday::HttpCache::Response, payload: response_payload, serializable_hash: response_payload) 19 | end 20 | 21 | let(:cache) { Faraday::HttpCache::MemoryStore.new } 22 | 23 | let(:strategy) { described_class.new(store: cache) } 24 | subject { strategy } 25 | 26 | describe 'storing responses' do 27 | shared_examples 'A strategy with serialization' do 28 | it 'writes the response object to the underlying cache' do 29 | entry = serializer.dump(response.serializable_hash) 30 | expect(cache).to receive(:write).with(vary_index_cache_key, vary) 31 | expect(cache).to receive(:write).with(cache_key, entry) 32 | subject.write(request, response) 33 | end 34 | end 35 | 36 | context 'with the JSON serializer' do 37 | let(:serializer) { JSON } 38 | it_behaves_like 'A strategy with serialization' 39 | 40 | context 'when ASCII characters in response cannot be converted to UTF-8', if: Gem::Version.new(RUBY_VERSION) < Gem::Version.new('3.1') do 41 | let(:response_payload) do 42 | body = String.new("\u2665").force_encoding('ASCII-8BIT') 43 | super().merge('body' => body) 44 | end 45 | 46 | it 'raises and logs a warning' do 47 | logger = double(:logger, warn: nil) 48 | strategy = described_class.new(logger: logger) 49 | 50 | expect { 51 | strategy.write(request, response) 52 | }.to raise_error(::Encoding::UndefinedConversionError) 53 | expect(logger).to have_received(:warn).with( 54 | 'Response could not be serialized: "\xE2" from ASCII-8BIT to UTF-8. Try using Marshal to serialize.' 55 | ) 56 | end 57 | end 58 | 59 | context 'with reordered and doubled values in the vary' do 60 | let(:vary) { 'X-Requested-With,Accept,Accept-Encoding,Accept' } 61 | 62 | it_behaves_like 'A strategy with serialization' 63 | end 64 | end 65 | 66 | context 'with the Marshal serializer' do 67 | let(:vary_index_cache_key) { '6a7cb42440c10ef6edeb1826086a4d90b04103f0' } 68 | let(:cache_key) { 'c9edbf280da95d4cac5acda8b8109c0aba2a469a' } 69 | let(:serializer) { Marshal } 70 | let(:strategy) { described_class.new(store: cache, serializer: Marshal) } 71 | 72 | it_behaves_like 'A strategy with serialization' 73 | end 74 | end 75 | 76 | describe 'reading responses' do 77 | let(:strategy) { described_class.new(store: cache, serializer: serializer) } 78 | 79 | shared_examples 'A strategy with serialization' do 80 | it 'returns nil if the response is not cached' do 81 | expect(subject.read(request)).to be_nil 82 | end 83 | 84 | it 'decodes a stored response' do 85 | subject.write(request, response) 86 | 87 | expect(subject.read(request)).to be_a(Faraday::HttpCache::Response) 88 | end 89 | end 90 | 91 | context 'with the JSON serializer' do 92 | let(:serializer) { JSON } 93 | 94 | it_behaves_like 'A strategy with serialization' 95 | end 96 | 97 | context 'with the Marshal serializer' do 98 | let(:serializer) { Marshal } 99 | 100 | it_behaves_like 'A strategy with serialization' 101 | end 102 | end 103 | 104 | describe 'deleting responses' do 105 | it 'ignores delete method' do 106 | subject.write(request, response) 107 | subject.delete(request.url) 108 | expect(subject.read(request)).not_to be_nil 109 | end 110 | end 111 | 112 | describe 'remove age before caching and normalize max-age if non-zero age present' do 113 | it 'is fresh if the response still has some time to live' do 114 | headers = { 115 | 'Age' => 6, 116 | 'Cache-Control' => 'public, max-age=40', 117 | 'Date' => (Time.now - 38).httpdate, 118 | 'Expires' => (Time.now - 37).httpdate, 119 | 'Last-Modified' => (Time.now - 300).httpdate 120 | } 121 | response = Faraday::HttpCache::Response.new(response_headers: headers) 122 | expect(response).to be_fresh 123 | subject.write(request, response) 124 | 125 | cached_response = subject.read(request) 126 | expect(cached_response.max_age).to eq(34) 127 | expect(cached_response).not_to be_fresh 128 | end 129 | 130 | it 'is fresh until cached and that 1 second elapses then the response is no longer fresh' do 131 | headers = { 132 | 'Date' => (Time.now - 39).httpdate, 133 | 'Expires' => (Time.now + 40).httpdate 134 | } 135 | 136 | response = Faraday::HttpCache::Response.new(response_headers: headers) 137 | expect(response).to be_fresh 138 | subject.write(request, response) 139 | 140 | sleep(1) 141 | cached_response = subject.read(request) 142 | expect(cached_response).not_to be_fresh 143 | end 144 | end 145 | end 146 | -------------------------------------------------------------------------------- /spec/support/empty.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sourcelevel/faraday-http-cache/f44ded4c9f6f794901c97eca1d82eb3ef2d5a5e6/spec/support/empty.png -------------------------------------------------------------------------------- /spec/support/test_app.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'sinatra/base' 4 | require 'json' 5 | 6 | class TestApp < Sinatra::Base 7 | set :environment, :test 8 | set :server, 'webrick' 9 | disable :protection 10 | 11 | set :counter, 0 12 | set :requests, 0 13 | set :yesterday, (Date.today - 1).httpdate 14 | 15 | get '/ping' do 16 | 'PONG' 17 | end 18 | 19 | get '/clear' do 20 | settings.counter = 0 21 | settings.requests = 0 22 | status 204 23 | end 24 | 25 | get '/json' do 26 | json = JSON.dump(count: increment_counter.to_i) 27 | [200, { 'Cache-Control' => 'max-age=400', 'Content-Type' => 'application/json' }, json] 28 | end 29 | 30 | get '/image' do 31 | image = File.expand_path('empty.png', __dir__) 32 | data = IO.binread(image) 33 | [200, { 'Cache-Control' => 'max-age=400', 'Content-Type' => 'image/png' }, data] 34 | end 35 | 36 | post '/post' do 37 | [200, { 'Cache-Control' => 'max-age=400' }, increment_counter] 38 | end 39 | 40 | get '/broken' do 41 | [500, { 'Cache-Control' => 'max-age=400' }, increment_counter] 42 | end 43 | 44 | get '/counter' do 45 | [200, { 'Cache-Control' => 'max-age=200' }, increment_counter] 46 | end 47 | 48 | post '/counter' do 49 | end 50 | 51 | put '/counter' do 52 | end 53 | 54 | delete '/counter' do 55 | end 56 | 57 | patch '/counter' do 58 | end 59 | 60 | get '/get' do 61 | [200, { 'Cache-Control' => 'max-age=200' }, increment_counter] 62 | end 63 | 64 | post '/delete-with-location' do 65 | [200, { 'Location' => "#{request.base_url}/get" }, ''] 66 | end 67 | 68 | post '/delete-with-content-location' do 69 | [200, { 'Content-Location' => "#{request.base_url}/get" }, ''] 70 | end 71 | 72 | post '/get' do 73 | halt 405 74 | end 75 | 76 | get '/private' do 77 | [200, { 'Cache-Control' => 'private, max-age=100' }, increment_counter] 78 | end 79 | 80 | get '/dontstore' do 81 | [200, { 'Cache-Control' => 'no-store' }, increment_counter] 82 | end 83 | 84 | get '/expires' do 85 | [200, { 'Expires' => (Time.now + 10).httpdate }, increment_counter] 86 | end 87 | 88 | get '/yesterday' do 89 | [200, { 'Date' => settings.yesterday, 'Expires' => settings.yesterday }, increment_counter] 90 | end 91 | 92 | get '/must-revalidate' do 93 | [200, { 'Date' => Time.now.httpdate, 'Cache-Control' => 'public, max-age=23880, must-revalidate, no-transform' }, increment_counter] 94 | end 95 | 96 | get '/timestamped' do 97 | settings.counter += 1 98 | header = settings.counter > 2 ? '1' : '2' 99 | 100 | if env['HTTP_IF_MODIFIED_SINCE'] == header 101 | [304, {}, ''] 102 | else 103 | [200, { 'Last-Modified' => header }, increment_counter] 104 | end 105 | end 106 | 107 | get '/etag' do 108 | settings.counter += 1 109 | tag = settings.counter > 2 ? '1' : '2' 110 | 111 | if env['HTTP_IF_NONE_MATCH'] == tag 112 | [304, { 'ETag' => tag, 'Cache-Control' => 'max-age=200', 'Date' => Time.now.httpdate, 'Expires' => (Time.now + 200).httpdate, 'Vary' => '*' }, ''] 113 | else 114 | [200, { 'ETag' => tag, 'Cache-Control' => 'max-age=0', 'Date' => settings.yesterday, 'Expires' => Time.now.httpdate, 'Vary' => 'Accept' }, increment_counter] 115 | end 116 | end 117 | 118 | get '/no_cache' do 119 | [200, { 'Cache-Control' => 'max-age=200, no-cache', 'ETag' => settings.counter.to_s }, increment_counter] 120 | end 121 | 122 | get '/vary' do 123 | [200, { 'Cache-Control' => 'max-age=50', 'Vary' => 'User-Agent' }, increment_counter] 124 | end 125 | 126 | get '/vary-wildcard' do 127 | [200, { 'Cache-Control' => 'max-age=50', 'Vary' => '*' }, increment_counter] 128 | end 129 | 130 | # Increments the 'requests' counter to act as a newly processed response. 131 | def increment_counter 132 | (settings.requests += 1).to_s 133 | end 134 | end 135 | -------------------------------------------------------------------------------- /spec/support/test_server.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'net/http' 4 | 5 | class TestServer 6 | attr_reader :endpoint 7 | 8 | def initialize 9 | @host = 'localhost' 10 | @port = find_port 11 | @endpoint = "http://#{@host}:#{@port}" 12 | end 13 | 14 | def start 15 | @pid = run! 16 | wait 17 | end 18 | 19 | def stop 20 | `kill -9 #{@pid}` 21 | end 22 | 23 | private 24 | 25 | def run! 26 | fork do 27 | require 'webrick' 28 | log = File.open('log/test.log', 'w+') 29 | log.sync = true 30 | webrick_opts = { 31 | Port: @port, 32 | Logger: WEBrick::Log.new(log), 33 | AccessLog: [[log, '[%{X-Faraday-Adapter}i] %m %U -> %s %b']] 34 | } 35 | Rack::Handler::WEBrick.run(TestApp, **webrick_opts) 36 | end 37 | end 38 | 39 | def wait 40 | conn = Net::HTTP.new @host, @port 41 | conn.open_timeout = conn.read_timeout = 0.1 42 | 43 | responsive = ->(path) { 44 | begin 45 | res = conn.start { conn.get(path) } 46 | res.is_a?(Net::HTTPSuccess) 47 | rescue Errno::ECONNREFUSED, Errno::EBADF, Timeout::Error, Net::HTTPBadResponse 48 | false 49 | end 50 | } 51 | 52 | server_pings = 0 53 | loop do 54 | break if responsive.call('/ping') 55 | 56 | server_pings += 1 57 | sleep 0.05 58 | abort 'test server did not managed to start' if server_pings >= 50 59 | end 60 | end 61 | 62 | def find_port 63 | server = TCPServer.new(@host, 0) 64 | server.addr[1] 65 | ensure 66 | server&.close 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /spec/validation_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe Faraday::HttpCache do 6 | let(:backend) { Faraday::Adapter::Test::Stubs.new } 7 | 8 | let(:client) do 9 | Faraday.new(url: ENV['FARADAY_SERVER']) do |stack| 10 | stack.use Faraday::HttpCache 11 | stack.adapter :test, backend 12 | end 13 | end 14 | 15 | it 'maintains the "Content-Type" header for cached responses' do 16 | backend.get('/test') { [200, { 'ETag' => '123ABC', 'Content-Type' => 'x' }, ''] } 17 | first_content_type = client.get('/test').headers['Content-Type'] 18 | 19 | # The Content-Type header of the validation response should be ignored. 20 | backend.get('/test') { [304, { 'Content-Type' => 'y' }, ''] } 21 | second_content_type = client.get('/test').headers['Content-Type'] 22 | 23 | expect(first_content_type).to eq('x') 24 | expect(second_content_type).to eq('x') 25 | end 26 | 27 | it 'maintains the "Content-Length" header for cached responses' do 28 | backend.get('/test') { [200, { 'ETag' => '123ABC', 'Content-Length' => 1 }, ''] } 29 | first_content_length = client.get('/test').headers['Content-Length'] 30 | 31 | # The Content-Length header of the validation response should be ignored. 32 | backend.get('/test') { [304, { 'Content-Length' => 2 }, ''] } 33 | second_content_length = client.get('/test').headers['Content-Length'] 34 | 35 | expect(first_content_length).to eq(1) 36 | expect(second_content_length).to eq(1) 37 | end 38 | end 39 | --------------------------------------------------------------------------------