Module: ConcernsOnRails::Controllers::Cacheable

Extended by:
ActiveSupport::Concern
Defined in:
lib/concerns_on_rails/controllers/cacheable.rb

Overview

HTTP conditional GET + declarative Cache-Control (“fresh_when/stale?-lite” for JSON APIs). Two layers:

1. A declarative `Cache-Control`/`Vary` POLICY per action (the macro).
2. Per-action validators (ETag / Last-Modified) with an automatic
   `304 Not Modified` short-circuit (the `stale_resource?` helper).

class Api::ArticlesController < ApplicationController
  include ConcernsOnRails::Controllers::Cacheable

  http_cache_actions :index, :show, max_age: 5.minutes,
                     visibility: :public, vary: "Accept"

  def show
    @article = Article.find(params[:id])
    return unless stale_resource?(@article)   # 304 + halt when client copy is fresh
    render json: @article
  end
end

The method names are deliberately distinct from Rails’ ‘ActionController::ConditionalGet` (`fresh_when` / `stale?` / `expires_in`) so including this concern in a real controller never shadows them.

Conditional-GET correctness (the value over a hand-rolled version):

* ETag is a WEAK validator `W/"<md5>"` derived from the resource's cache
  key — appropriate for serialized representations (semantic, not
  byte-for-byte, equivalence). `If-None-Match` is matched with weak
  comparison, honours `*`, and accepts a comma-separated list.
* `Last-Modified` is an IMF-fixdate via `Time#httpdate` (NOT ISO 8601 —
  the classic bug). `If-Modified-Since` is compared at whole-second
  granularity (HTTP dates carry no sub-second part).
* When BOTH `If-None-Match` and `If-Modified-Since` are sent, the ETag
  wins and the date is ignored (RFC 7232 §3.3).
* The 304 is only sent for safe requests (GET/HEAD) and still carries the
  validators AND the policy headers (Cache-Control rides the 304, like
  Deprecatable's headers ride the 410).

Notes:

* `no_store: true` overrides everything (emits the lone `no-store`).
* `Vary` is appended to any existing `Vary` header, de-duplicated.
* No positional actions = catch-all; the LAST matching rule wins (the
  Deprecatable convention — caching policy is an override).
* Works on bare objects (every `request`/`response` touch is guarded), so
  it is testable without the full Rails stack.
* For write-side preconditions (`If-Match` / `If-Unmodified-Since` → 412)
  reach for Rails' own conditional-GET helpers; this concern covers the
  read path.

Defined Under Namespace

Modules: ClassMethods

Constant Summary collapse

LABEL =
"ConcernsOnRails::Controllers::Cacheable".freeze
VALID_VISIBILITY =
%i[public private].freeze
SAFE_METHODS =
%w[GET HEAD].freeze

Instance Method Summary collapse

Instance Method Details

#apply_http_cache_headersObject

after_action entry point. Public so host apps can ‘skip_after_action` it or override it. Emits the matching action’s Cache-Control + Vary.



135
136
137
138
139
140
141
142
143
144
# File 'lib/concerns_on_rails/controllers/cacheable.rb', line 135

def apply_http_cache_headers
  rule = http_cache_rule_for_action
  return unless rule
  return unless respond_to?(:response) && response

  value = http_cache_control_value(rule)
  response.set_header("Cache-Control", value) if value
  response.set_header("Vary", http_cache_merge_vary(rule[:vary])) if rule[:vary]
  nil
end

#cache_etag_for(resource) ⇒ Object

Override points for deriving validators from a resource.



183
184
185
# File 'lib/concerns_on_rails/controllers/cacheable.rb', line 183

def cache_etag_for(resource)
  http_cache_weak_etag(http_cache_key_for(resource))
end

#cache_last_modified_for(resource) ⇒ Object



187
188
189
# File 'lib/concerns_on_rails/controllers/cacheable.rb', line 187

def cache_last_modified_for(resource)
  http_cache_timestamp_for(resource)
end

#request_matches_cache?(etag: nil, last_modified: nil) ⇒ Boolean

Side-effect-free: does the request’s precondition match these validators?

Returns:

  • (Boolean)


168
169
170
171
172
173
174
175
176
177
178
179
180
# File 'lib/concerns_on_rails/controllers/cacheable.rb', line 168

def request_matches_cache?(etag: nil, last_modified: nil)
  if_none_match = http_cache_request_header("If-None-Match")
  if_modified_since = http_cache_request_header("If-Modified-Since")

  # If-None-Match takes precedence when present (RFC 7232 §3.3).
  if if_none_match
    etag && http_cache_etag_matches?(if_none_match, etag)
  elsif if_modified_since
    last_modified ? http_cache_not_modified_since?(if_modified_since, last_modified) : false
  else
    false
  end
end

#set_cache_validators(resource = nil, etag: nil, last_modified: nil) ⇒ Object

Set the ETag/Last-Modified response headers (no short-circuit). Returns the computed { etag:, last_modified: } pair.



160
161
162
163
164
165
# File 'lib/concerns_on_rails/controllers/cacheable.rb', line 160

def set_cache_validators(resource = nil, etag: nil, last_modified: nil)
  etag ||= cache_etag_for(resource) unless resource.nil?
  last_modified ||= cache_last_modified_for(resource) unless resource.nil?
  http_cache_write_validators(etag, last_modified)
  { etag: etag, last_modified: last_modified }
end

#stale_resource?(resource = nil, etag: nil, last_modified: nil) ⇒ Boolean

Set ETag/Last-Modified validators for the resource, then — for a safe request whose precondition matches — send 304 and return false. Returns true when the client must be sent a fresh body. Mirrors Rails ‘stale?`.

Returns:

  • (Boolean)


149
150
151
152
153
154
155
156
# File 'lib/concerns_on_rails/controllers/cacheable.rb', line 149

def stale_resource?(resource = nil, etag: nil, last_modified: nil)
  validators = set_cache_validators(resource, etag: etag, last_modified: last_modified)
  return true unless http_cache_safe_request?
  return true unless request_matches_cache?(etag: validators[:etag], last_modified: validators[:last_modified])

  http_cache_send_not_modified
  false
end