actionpack/lib/action_controller/metal/conditional_get.rb

Summary

Maintainability
A
1 hr
Test Coverage
# frozen_string_literal: true

# :markup: markdown

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 `304
    # Not Modified` 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 `304 Not Modified` 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 `304 Not Modified` 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 `304 Not
    #     Modified` 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-Contr
    #     ol).
    #
    # `: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 `template: false` 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 `304 Not Modified` 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 `maximum(:updated_at)` (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 `Cache-Control: public, no-cache` 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 `304 Not Modified` 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 `maximum(:updated_at)` (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 `Cache-Control: public, no-cache` 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 `no-cache`. 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 `no-store`. 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