Module: ConcernsOnRails::Controllers::Deprecatable

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

Overview

Standards-based API endpoint deprecation — the receiving end of an API retirement plan (how Stripe / GitHub / Zalando sunset versions). Declare which actions are deprecated and the standard signalling headers go out on every response so clients (and their SDKs) can discover the deprecation, the migration docs, the successor endpoint, and the hard cut-off — then optionally enforce a 410 Gone once that cut-off passes.

class Api::V1::OrdersController < ApplicationController
  include ConcernsOnRails::Controllers::Deprecatable

  deprecate_actions :index, :show,
    deprecated_at: "2026-06-01",
    sunset_at:     "2026-12-31T00:00:00Z",
    link:          "https://docs.example.com/v1-migration",
    successor:     "https://api.example.com/v2/orders",
    after_sunset:  :gone,
    notify:        -> { StatsD.increment("api.v1.orders.deprecated") }
end

Headers emitted (always — including on the 410, so the failure is self-documenting):

* Deprecation — RFC 9745. The final form is a structured-fields Date
  item: "@<unix-seconds>" of `deprecated_at`. `header_format: :legacy`
  emits the still-widely-deployed pre-RFC draft form, the literal "true".
* Sunset      — RFC 8594. An IMF-fixdate (HTTP-date) via Time#httpdate,
  NOT ISO 8601 — hand-rolling that is the classic bug.
* Link        — RFC 8288. rel="deprecation" (the migration doc) and/or
  rel="successor-version" (the replacement endpoint), APPENDED to any
  Link header already on the response (pagination / CDN), never clobbered.

‘sunset_at` is an INSTANT, not a calendar day: a bare date “2026-12-31” is normalised to 00:00 UTC, so the endpoint dies at the START of that day, not end-of-day. Times are parsed eagerly at declaration time and normalised to UTC.

THE LAST MATCHING RULE WINS (deliberately the reverse of Idempotentable’s first-match): deprecation rules are configuration overrides, not guards, so a V1 base controller’s catch-all ‘deprecate_actions` is naturally overridden by a later, action-specific declaration in a subclass. Exactly one rule applies per request and exactly one Deprecation header is emitted. With no positional actions a rule is a catch-all for the whole controller (the WebhookVerifiable convention).

‘after_sunset: :gone` (requires `sunset_at`) halts with 410 once the sunset instant is reached (the boundary instant counts as sunset — inclusive). The default `:headers` NEVER blocks, however long past sunset — flip to `:gone` only once metrics show callers have migrated. `on_deprecated_access` is that metrics seam: it instruments “deprecated_endpoint.concerns_on_rails” and runs `notify:`. A raising `notify` propagates on purpose — a broken metrics hook should be loud, not silently swallowed (WebhookVerifiable’s stance).

Defined Under Namespace

Modules: ClassMethods

Constant Summary collapse

LABEL =
"ConcernsOnRails::Controllers::Deprecatable".freeze
VALID_AFTER_SUNSET =
%i[headers gone].freeze
VALID_HEADER_FORMATS =
%i[rfc9745 legacy].freeze
UNPARSEABLE =
"could not be parsed (pass a Time, Date, DateTime, or parseable String)".freeze

Instance Method Summary collapse

Instance Method Details

#apply_api_deprecationsObject

before_action entry point. Public so host apps can ‘skip_before_action :apply_api_deprecations` or override it.



159
160
161
162
163
164
165
166
167
168
169
# File 'lib/concerns_on_rails/controllers/deprecatable.rb', line 159

def apply_api_deprecations
  rule = deprecation_rule_for_action
  return nil unless rule

  # Order matters: headers go out unconditionally (so even the 410 carries
  # them), THEN we record the access, THEN enforce. Recording before
  # enforcing means metrics still count callers who get the 410.
  emit_deprecation_headers(rule)
  on_deprecated_access(rule)
  enforce_deprecation_sunset(rule)
end

#deprecation_active?Boolean

True when some rule covers the current action.

Returns:

  • (Boolean)


185
186
187
# File 'lib/concerns_on_rails/controllers/deprecatable.rb', line 185

def deprecation_active?
  !deprecation_rule_for_action.nil?
end

#on_deprecated_access(rule) ⇒ Object

Public override point + instrumentation seam. Default: emit an ActiveSupport::Notifications event and run the rule’s ‘notify:` callable (instance_exec’d, so it can read controller state). A raising ‘notify` propagates by design.



175
176
177
178
179
180
181
182
# File 'lib/concerns_on_rails/controllers/deprecatable.rb', line 175

def on_deprecated_access(rule)
  ActiveSupport::Notifications.instrument(
    "deprecated_endpoint.concerns_on_rails",
    controller: deprecation_controller_name, action: deprecation_action_name,
    deprecated_at: rule[:deprecated_at], sunset_at: rule[:sunset_at]
  )
  instance_exec(&rule[:notify]) if rule[:notify]
end

#sunset_passed?Boolean

True when the matching rule has a sunset_at that the clock has reached.

Returns:

  • (Boolean)


190
191
192
# File 'lib/concerns_on_rails/controllers/deprecatable.rb', line 190

def sunset_passed?
  deprecation_sunset_reached?(deprecation_rule_for_action)
end