# frozen_string_literal: true
require "active_support/core_ext/object/try"
require "active_support/core_ext/integer/time"
module ActionController
module ConditionalGet
extend ActiveSupport::Concern
include Head
included do
class_attribute :etaggers, default: []
end
module ClassMethods
# Allows you to consider additional controller-wide information when generating an ETag.
# For example, if you serve pages tailored depending on who's logged in at the moment, you
# may want to add the current user id to be part of the ETag to prevent unauthorized displaying
# of cached pages.
#
# class InvoicesController < ApplicationController
# etag { current_user&.id }
#
# def show
# # Etag will differ even for the same invoice when it's viewed by a different current_user
# @invoice = Invoice.find(params[:id])
# fresh_when etag: @invoice
# end
# end
def etag(&etagger)
self.etaggers += [etagger]
end
end
# Sets the +etag+, +last_modified+, or both on the response, and renders a
# <tt>304 Not Modified</tt> response if the request is already fresh.
#
# ==== Options
#
# [+:etag+]
# Sets a "weak" ETag validator on the response. See the +:weak_etag+ option.
# [+:weak_etag+]
# Sets a "weak" ETag validator on the response. Requests that specify an
# +If-None-Match+ header may receive a <tt>304 Not Modified</tt> response
# if the ETag matches exactly.
#
# A weak ETag indicates semantic equivalence, not byte-for-byte equality,
# so they're good for caching HTML pages in browser caches. They can't be
# used for responses that must be byte-identical, like serving +Range+
# requests within a PDF file.
# [+:strong_etag+]
# Sets a "strong" ETag validator on the response. Requests that specify an
# +If-None-Match+ header may receive a <tt>304 Not Modified</tt> response
# if the ETag matches exactly.
#
# A strong ETag implies exact equality -- the response must match byte for
# byte. This is necessary for serving +Range+ requests within a large
# video or PDF file, for example, or for compatibility with some CDNs that
# don't support weak ETags.
# [+:last_modified+]
# Sets a "weak" last-update validator on the response. Subsequent requests
# that specify an +If-Modified-Since+ header may receive a <tt>304 Not Modified</tt>
# response if +last_modified+ <= +If-Modified-Since+.
# [+:public+]
# By default the +Cache-Control+ header is private. Set this option to
# +true+ if you want your application to be cacheable by other devices,
# such as proxy caches.
# [+:cache_control+]
# When given, will overwrite an existing +Cache-Control+ header. For a
# list of +Cache-Control+ directives, see the {article on
# MDN}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control].
# [+:template+]
# By default, the template digest for the current controller/action is
# included in ETags. If the action renders a different template, you can
# include its digest instead. If the action doesn't render a template at
# all, you can pass <tt>template: false</tt> to skip any attempt to check
# for a template digest.
#
# ==== Examples
#
# def show
# @article = Article.find(params[:id])
# fresh_when(etag: @article, last_modified: @article.updated_at, public: true)
# end
#
# This will send a <tt>304 Not Modified</tt> response if the request
# specifies a matching ETag and +If-Modified-Since+ header. Otherwise, it
# will render the +show+ template.
#
# You can also just pass a record:
#
# def show
# @article = Article.find(params[:id])
# fresh_when(@article)
# end
#
# +etag+ will be set to the record, and +last_modified+ will be set to the
# record's +updated_at+.
#
# You can also pass an object that responds to +maximum+, such as a
# collection of records:
#
# def index
# @articles = Article.all
# fresh_when(@articles)
# end
#
# In this case, +etag+ will be set to the collection, and +last_modified+
# will be set to <tt>maximum(:updated_at)</tt> (the timestamp of the most
# recently updated record).
#
# When passing a record or a collection, you can still specify other
# options, such as +:public+ and +:cache_control+:
#
# def show
# @article = Article.find(params[:id])
# fresh_when(@article, public: true, cache_control: { no_cache: true })
# end
#
# The above will set <tt>Cache-Control: public, no-cache</tt> in the response.
#
# When rendering a different template than the controller/action's default
# template, you can indicate which digest to include in the ETag:
#
# before_action { fresh_when @article, template: "widgets/show" }
#
def fresh_when(object = nil, etag: nil, weak_etag: nil, strong_etag: nil, last_modified: nil, public: false, cache_control: {}, template: nil)
response.cache_control.delete(:no_store)
weak_etag ||= etag || object unless strong_etag
last_modified ||= object.try(:updated_at) || object.try(:maximum, :updated_at)
if strong_etag
response.strong_etag = combine_etags strong_etag,
last_modified: last_modified, public: public, template: template
elsif weak_etag || template
response.weak_etag = combine_etags weak_etag,
last_modified: last_modified, public: public, template: template
end
response.last_modified = last_modified if last_modified
response.cache_control[:public] = true if public
response.cache_control.merge!(cache_control)
head :not_modified if request.fresh?(response)
end
# Sets the +etag+ and/or +last_modified+ on the response and checks them
# against the request. If the request doesn't match the provided options, it
# is considered stale, and the response should be rendered from scratch.
# Otherwise, it is fresh, and a <tt>304 Not Modified</tt> is sent.
#
# ==== Options
#
# See #fresh_when for supported options.
#
# ==== Examples
#
# def show
# @article = Article.find(params[:id])
#
# if stale?(etag: @article, last_modified: @article.updated_at)
# @statistics = @article.really_expensive_call
# respond_to do |format|
# # all the supported formats
# end
# end
# end
#
# You can also just pass a record:
#
# def show
# @article = Article.find(params[:id])
#
# if stale?(@article)
# @statistics = @article.really_expensive_call
# respond_to do |format|
# # all the supported formats
# end
# end
# end
#
# +etag+ will be set to the record, and +last_modified+ will be set to the
# record's +updated_at+.
#
# You can also pass an object that responds to +maximum+, such as a
# collection of records:
#
# def index
# @articles = Article.all
#
# if stale?(@articles)
# @statistics = @articles.really_expensive_call
# respond_to do |format|
# # all the supported formats
# end
# end
# end
#
# In this case, +etag+ will be set to the collection, and +last_modified+
# will be set to <tt>maximum(:updated_at)</tt> (the timestamp of the most
# recently updated record).
#
# When passing a record or a collection, you can still specify other
# options, such as +:public+ and +:cache_control+:
#
# def show
# @article = Article.find(params[:id])
#
# if stale?(@article, public: true, cache_control: { no_cache: true })
# @statistics = @articles.really_expensive_call
# respond_to do |format|
# # all the supported formats
# end
# end
# end
#
# The above will set <tt>Cache-Control: public, no-cache</tt> in the response.
#
# When rendering a different template than the controller/action's default
# template, you can indicate which digest to include in the ETag:
#
# def show
# super if stale?(@article, template: "widgets/show")
# end
#
def stale?(object = nil, **freshness_kwargs)
fresh_when(object, **freshness_kwargs)
!request.fresh?(response)
end
# Sets the +Cache-Control+ header, overwriting existing directives. This
# method will also ensure an HTTP +Date+ header for client compatibility.
#
# Defaults to issuing the +private+ directive, so that intermediate caches
# must not cache the response.
#
# ==== Options
#
# [+:public+]
# If true, replaces the default +private+ directive with the +public+
# directive.
# [+:must_revalidate+]
# If true, adds the +must-revalidate+ directive.
# [+:stale_while_revalidate+]
# Sets the value of the +stale-while-revalidate+ directive.
# [+:stale_if_error+]
# Sets the value of the +stale-if-error+ directive.
#
# Any additional key-value pairs are concatenated as directives. For a list
# of supported +Cache-Control+ directives, see the {article on
# MDN}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control].
#
# ==== Examples
#
# expires_in 10.minutes
# # => Cache-Control: max-age=600, private
#
# expires_in 10.minutes, public: true
# # => Cache-Control: max-age=600, public
#
# expires_in 10.minutes, public: true, must_revalidate: true
# # => Cache-Control: max-age=600, public, must-revalidate
#
# expires_in 1.hour, stale_while_revalidate: 60.seconds
# # => Cache-Control: max-age=3600, private, stale-while-revalidate=60
#
# expires_in 1.hour, stale_if_error: 5.minutes
# # => Cache-Control: max-age=3600, private, stale-if-error=300
#
# expires_in 1.hour, public: true, "s-maxage": 3.hours, "no-transform": true
# # => Cache-Control: max-age=3600, public, s-maxage=10800, no-transform=true
#
def expires_in(seconds, options = {})
response.cache_control.delete(:no_store)
response.cache_control.merge!(
max_age: seconds,
public: options.delete(:public),
must_revalidate: options.delete(:must_revalidate),
stale_while_revalidate: options.delete(:stale_while_revalidate),
stale_if_error: options.delete(:stale_if_error),
)
options.delete(:private)
response.cache_control[:extras] = options.map { |k, v| "#{k}=#{v}" }
response.date = Time.now unless response.date?
end
# Sets an HTTP 1.1 +Cache-Control+ header of <tt>no-cache</tt>. This means the
# resource will be marked as stale, so clients must always revalidate.
# Intermediate/browser caches may still store the asset.
def expires_now
response.cache_control.replace(no_cache: true)
end
# Cache or yield the block. The cache is supposed to never expire.
#
# You can use this method when you have an HTTP response that never changes,
# and the browser and proxies should cache it indefinitely.
#
# * +public+: By default, HTTP responses are private, cached only on the
# user's web browser. To allow proxies to cache the response, set +true+ to
# indicate that they can serve the cached response to all users.
def http_cache_forever(public: false)
expires_in 100.years, public: public
yield if stale?(etag: request.fullpath,
last_modified: Time.new(2011, 1, 1).utc,
public: public)
end
# Sets an HTTP 1.1 +Cache-Control+ header of <tt>no-store</tt>. This means the
# resource may not be stored in any cache.
def no_store
response.cache_control.replace(no_store: true)
end
private
def combine_etags(validator, options)
[validator, *etaggers.map { |etagger| instance_exec(options, &etagger) }].compact
end
end
end