Repository: mastodon/mastodon
Pull request #34262: Add `Deprecation` headers on deprecated endpoints
Author: login: ClearlyClaire
Created: 2025-03-24T15:23:15Z | Merged: 2025-03-25T12:40:57Z

Fixes #30308

Linked issue #30308: Advertise deprecated endpoints via Deprecation headers ( draft-ietf-httpapi-deprecation-header-03 )
### Pitch

Currently notices of deprecated endpoints are exclusively listed in that endpoints' documentation Version History. This means that people may not notice that they are using deprecated endpoints, and API Clients can't automatically warn when a deprecated API is used.

I'd like to propose that we adopt [draft-ietf-httpapi-deprecation-header-03](https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-deprecation-header-03), and advertise as follows:

```text
Deprecation: @1688169599
Link: <https://docs.joinmastodon.org/api/deprecations#<id>; rel="deprecation"; type="text/html"
```

Where `1688169599` is when the endpoint was or will be deprecated at, in seconds; and `<id>` is a unique identified for that deprecation on the deprecation pages.

We may also have use in the `Sunset` HTTP Header, however, this would require fairly exact release dates, which we don't currently have, or we use the `sunset` rel in a Link header, to indicate upcoming removal of an API. (but I'm not sure if we've ever completely removed an API, a breaking change.

There is a [ticket on the documentation](https://github.com/mastodon/documentation/issues/1443) repository which is complementary to this.

### Motivation

This will help developers be better able to discover when they're using outdated API endpoints, and help them upgrade, ensuring a healthier API community usage.
Linked issue comments: user: login: LucasGGamerM
created_at: 2024-05-15T14:59:37Z
body: I am in favor of this 
author_association: NONE
user: login: VyrCossont
created_at: 2024-05-15T16:05:34Z
body: Makes sense for client library developers and bot operators, who would be in a position to see error messages if their client library was aware of these headers. I assume this isn't meant to be surfaced to end users.
author_association: CONTRIBUTOR
user: login: ThisIsMissEm
created_at: 2024-05-15T16:42:20Z
body: > I assume this isn't meant to be surfaced to end users.

@VyrCossont correct! Though, if you're a developer using a client library, then the client library may choose to expose this information to you via logging.
author_association: CONTRIBUTOR

Diff:
diff --git a/app/controllers/api/v1/accounts/identity_proofs_controller.rb b/app/controllers/api/v1/accounts/identity_proofs_controller.rb
index 48f293f47a325b..02a45e87587157 100644
--- a/app/controllers/api/v1/accounts/identity_proofs_controller.rb
+++ b/app/controllers/api/v1/accounts/identity_proofs_controller.rb
@@ -1,6 +1,10 @@
 # frozen_string_literal: true
 
 class Api::V1::Accounts::IdentityProofsController < Api::BaseController
+  include DeprecationConcern
+
+  deprecate_api '2022-03-30'
+
   before_action :require_user!
   before_action :set_account
 
diff --git a/app/controllers/api/v1/filters_controller.rb b/app/controllers/api/v1/filters_controller.rb
index ed98acce30f457..5e9ba5153c0481 100644
--- a/app/controllers/api/v1/filters_controller.rb
+++ b/app/controllers/api/v1/filters_controller.rb
@@ -1,6 +1,10 @@
 # frozen_string_literal: true
 
 class Api::V1::FiltersController < Api::BaseController
+  include DeprecationConcern
+
+  deprecate_api '2022-11-14'
+
   before_action -> { doorkeeper_authorize! :read, :'read:filters' }, only: [:index, :show]
   before_action -> { doorkeeper_authorize! :write, :'write:filters' }, except: [:index, :show]
   before_action :require_user!
diff --git a/app/controllers/api/v1/instances_controller.rb b/app/controllers/api/v1/instances_controller.rb
index 49da75ed285b1a..e01267c0008122 100644
--- a/app/controllers/api/v1/instances_controller.rb
+++ b/app/controllers/api/v1/instances_controller.rb
@@ -1,15 +1,9 @@
 # frozen_string_literal: true
 
-class Api::V1::InstancesController < Api::BaseController
-  skip_before_action :require_authenticated_user!, unless: :limited_federation_mode?
-  skip_around_action :set_locale
+class Api::V1::InstancesController < Api::V2::InstancesController
+  include DeprecationConcern
 
-  vary_by ''
-
-  # Override `current_user` to avoid reading session cookies unless in limited federation mode
-  def current_user
-    super if limited_federation_mode?
-  end
+  deprecate_api '2022-11-14'
 
   def show
     cache_even_if_authenticated!
diff --git a/app/controllers/api/v1/suggestions_controller.rb b/app/controllers/api/v1/suggestions_controller.rb
index 9ba1cef63cab75..918ec45bebf9a9 100644
--- a/app/controllers/api/v1/suggestions_controller.rb
+++ b/app/controllers/api/v1/suggestions_controller.rb
@@ -2,6 +2,9 @@
 
 class Api::V1::SuggestionsController < Api::BaseController
   include Authorization
+  include DeprecationConcern
+
+  deprecate_api '2021-05-16'
 
   before_action -> { doorkeeper_authorize! :read, :'read:accounts' }, only: :index
   before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, except: :index
diff --git a/app/controllers/api/v1/trends/tags_controller.rb b/app/controllers/api/v1/trends/tags_controller.rb
index 10a34423446d1a..f84f1c0252790f 100644
--- a/app/controllers/api/v1/trends/tags_controller.rb
+++ b/app/controllers/api/v1/trends/tags_controller.rb
@@ -1,12 +1,16 @@
 # frozen_string_literal: true
 
 class Api::V1::Trends::TagsController < Api::BaseController
+  include DeprecationConcern
+
   before_action :set_tags
 
   after_action :insert_pagination_headers
 
   DEFAULT_TAGS_LIMIT = 10
 
+  deprecate_api '2022-03-30', only: :index, if: -> { request.path == '/api/v1/trends' }
+
   def index
     cache_if_unauthenticated!
     render json: @tags, each_serializer: REST::TagSerializer, relationships: TagRelationshipsPresenter.new(@tags, current_user&.account_id)
diff --git a/app/controllers/api/v2/instances_controller.rb b/app/controllers/api/v2/instances_controller.rb
index 8346e28830a579..62adf952608d8a 100644
--- a/app/controllers/api/v2/instances_controller.rb
+++ b/app/controllers/api/v2/instances_controller.rb
@@ -1,6 +1,16 @@
 # frozen_string_literal: true
 
-class Api::V2::InstancesController < Api::V1::InstancesController
+class Api::V2::InstancesController < Api::BaseController
+  skip_before_action :require_authenticated_user!, unless: :limited_federation_mode?
+  skip_around_action :set_locale
+
+  vary_by ''
+
+  # Override `current_user` to avoid reading session cookies unless in limited federation mode
+  def current_user
+    super if limited_federation_mode?
+  end
+
   def show
     cache_even_if_authenticated!
     render_with_cache json: InstancePresenter.new, serializer: REST::InstanceSerializer, root: 'instance'
diff --git a/app/controllers/concerns/deprecation_concern.rb b/app/controllers/concerns/deprecation_concern.rb
new file mode 100644
index 00000000000000..ad8de724a1cd81
--- /dev/null
+++ b/app/controllers/concerns/deprecation_concern.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module DeprecationConcern
+  extend ActiveSupport::Concern
+
+  class_methods do
+    def deprecate_api(date, sunset: nil, **kwargs)
+      deprecation_timestamp = "@#{date.to_datetime.to_i}"
+      sunset = sunset&.to_date&.httpdate
+
+      before_action(**kwargs) do
+        response.headers['Deprecation'] = deprecation_timestamp
+        response.headers['Sunset'] = sunset if sunset
+      end
+    end
+  end
+end
diff --git a/app/javascript/mastodon/api.ts b/app/javascript/mastodon/api.ts
index f0663ded409f65..a41b058d2c2ee1 100644
--- a/app/javascript/mastodon/api.ts
+++ b/app/javascript/mastodon/api.ts
@@ -1,4 +1,9 @@
-import type { AxiosResponse, Method, RawAxiosRequestHeaders } from 'axios';
+import type {
+  AxiosError,
+  AxiosResponse,
+  Method,
+  RawAxiosRequestHeaders,
+} from 'axios';
 import axios from 'axios';
 import LinkHeader from 'http-link-header';
 
@@ -41,7 +46,7 @@ const authorizationTokenFromInitialState = (): RawAxiosRequestHeaders => {
 
 // eslint-disable-next-line import/no-default-export
 export default function api(withAuthorization = true) {
-  return axios.create({
+  const instance = axios.create({
     transitional: {
       clarifyTimeoutError: true,
     },
@@ -60,6 +65,22 @@ export default function api(withAuthorization = true) {
       },
     ],
   });
+
+  instance.interceptors.response.use(
+    (response: AxiosResponse) => {
+      if (response.headers.deprecation) {
+        console.warn(
+          `Deprecated request: ${response.config.method} ${response.config.url}`,
+        );
+      }
+      return response;
+    },
+    (error: AxiosError) => {
+      return Promise.reject(error);
+    },
+  );
+
+  return instance;
 }
 
 type RequestParamsOrData = Record<string, unknown>;
diff --git a/spec/requests/api/v1/trends/tags_spec.rb b/spec/requests/api/v1/trends/tags_spec.rb
index 14ab73fc96c30e..097393e58db3bc 100644
--- a/spec/requests/api/v1/trends/tags_spec.rb
+++ b/spec/requests/api/v1/trends/tags_spec.rb
@@ -15,6 +15,8 @@
           .and not_have_http_link_header
         expect(response.content_type)
           .to start_with('application/json')
+        expect(response.headers['Deprecation'])
+          .to be_nil
       end
     end
 
@@ -31,6 +33,8 @@
           .and have_http_link_header(api_v1_trends_tags_url(offset: 2)).for(rel: 'next')
         expect(response.content_type)
           .to start_with('application/json')
+        expect(response.headers['Deprecation'])
+          .to be_nil
       end
 
       def prepare_trends
diff --git a/spec/requests/api/v1/trends_spec.rb b/spec/requests/api/v1/trends_spec.rb
new file mode 100644
index 00000000000000..5bfabdca1c65f0
--- /dev/null
+++ b/spec/requests/api/v1/trends_spec.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'deprecated API V1 Trends Tags' do
+  describe 'GET /api/v1/trends' do
+    context 'when trends are disabled' do
+      before { Setting.trends = false }
+
+      it 'returns http success' do
+        get '/api/v1/trends'
+
+        expect(response)
+          .to have_http_status(200)
+          .and not_have_http_link_header
+        expect(response.content_type)
+          .to start_with('application/json')
+        expect(response.headers['Deprecation'])
+          .to start_with '@'
+      end
+    end
+
+    context 'when trends are enabled' do
+      before { Setting.trends = true }
+
+      it 'returns http success' do
+        prepare_trends
+        stub_const('Api::V1::Trends::TagsController::DEFAULT_TAGS_LIMIT', 2)
+        get '/api/v1/trends'
+
+        expect(response)
+          .to have_http_status(200)
+          .and have_http_link_header(api_v1_trends_tags_url(offset: 2)).for(rel: 'next')
+        expect(response.content_type)
+          .to start_with('application/json')
+        expect(response.headers['Deprecation'])
+          .to start_with '@'
+      end
+
+      def prepare_trends
+        Fabricate.times(3, :tag, trendable: true).each do |tag|
+          2.times { |i| Trends.tags.add(tag, i) }
+        end
+        Trends::Tags.new(threshold: 1).refresh
+      end
+    end
+  end
+end


Reviews:
user: login: dmathieu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T09:04:58Z
user: login: dmathieu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T10:32:33Z
user: login: ClearlyClaire
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T10:49:39Z
user: login: oneiros
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T10:59:19Z
user: login: ClearlyClaire
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T12:04:39Z
user: login: ClearlyClaire
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T12:08:03Z
user: login: oneiros
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T12:15:52Z
user: login: oneiros
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T12:15:56Z

Review comments:
diff_hunk: @@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module DeprecationConcern
+  extend ActiveSupport::Concern
+
+  class_methods do
+    def deprecate_api(date, sunset: nil, **kwargs)
+      deprecation_timestamp = "@#{date.to_date.to_i}"
+      sunset = sunset&.to_date&.httpdate
+
+      before_action(**kwargs) do |controller|
+        response.headers['Deprecatation'] = deprecation_timestamp
path: app/controllers/concerns/deprecation_concern.rb
user: login: dmathieu
body: ```suggestion
        response.headers['Deprecation'] = deprecation_timestamp
```
created_at: 2025-03-25T09:04:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module DeprecationConcern
+  extend ActiveSupport::Concern
+
+  class_methods do
+    def deprecate_api(date, sunset: nil, **kwargs)
+      deprecation_timestamp = "@#{date.to_datetime.to_i}"
+      sunset = sunset&.to_date&.httpdate
+
+      before_action(**kwargs) do
+        response.headers['Deprecation'] = deprecation_timestamp
+        response.headers['Sunset'] = sunset if sunset
path: app/controllers/concerns/deprecation_concern.rb
user: login: dmathieu
body: If the date has passed, should it return an error instead? Or do you plan to handle making the removal effective elsewhere?
created_at: 2025-03-25T10:32:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module DeprecationConcern
+  extend ActiveSupport::Concern
+
+  class_methods do
+    def deprecate_api(date, sunset: nil, **kwargs)
+      deprecation_timestamp = "@#{date.to_datetime.to_i}"
+      sunset = sunset&.to_date&.httpdate
+
+      before_action(**kwargs) do
+        response.headers['Deprecation'] = deprecation_timestamp
+        response.headers['Sunset'] = sunset if sunset
path: app/controllers/concerns/deprecation_concern.rb
user: login: ClearlyClaire
body: I don't think it makes sense to return errors in this way, errorring out would be because we removed the deprecated controller. This was more about preparing for potentially deciding to retire endpoints in advance (up until now, we have removed very few endpoints, and we never had a real process for that)
created_at: 2025-03-25T10:49:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,15 +1,9 @@
 # frozen_string_literal: true
 
-class Api::V1::InstancesController < Api::BaseController
-  skip_before_action :require_authenticated_user!, unless: :limited_federation_mode?
-  skip_around_action :set_locale
+class Api::V1::InstancesController < Api::V2::InstancesController
+  include DeprecationConcern
 
-  vary_by ''
-
-  # Override `current_user` to avoid reading session cookies unless in limited federation mode
-  def current_user
-    super if limited_federation_mode?
-  end
path: app/controllers/api/v1/instances_controller.rb
user: login: oneiros
body: Why were the removed lines moved to the v2 controller?
created_at: 2025-03-25T10:59:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,15 +1,9 @@
 # frozen_string_literal: true
 
-class Api::V1::InstancesController < Api::BaseController
-  skip_before_action :require_authenticated_user!, unless: :limited_federation_mode?
-  skip_around_action :set_locale
+class Api::V1::InstancesController < Api::V2::InstancesController
+  include DeprecationConcern
 
-  vary_by ''
-
-  # Override `current_user` to avoid reading session cookies unless in limited federation mode
-  def current_user
-    super if limited_federation_mode?
-  end
path: app/controllers/api/v1/instances_controller.rb
user: login: ClearlyClaire
body: I swapped the inheritance around so that v2 wouldn't inherit from v1's deprecated status.
created_at: 2025-03-25T12:04:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,15 +1,9 @@
 # frozen_string_literal: true
 
-class Api::V1::InstancesController < Api::BaseController
-  skip_before_action :require_authenticated_user!, unless: :limited_federation_mode?
-  skip_around_action :set_locale
+class Api::V1::InstancesController < Api::V2::InstancesController
+  include DeprecationConcern
 
-  vary_by ''
-
-  # Override `current_user` to avoid reading session cookies unless in limited federation mode
-  def current_user
-    super if limited_federation_mode?
-  end
path: app/controllers/api/v1/instances_controller.rb
user: login: ClearlyClaire
body: An alternative would be to move have that logic be handled in `show` instead of in a class method. This could make sense as well if we have more specific deprecations to convey.
created_at: 2025-03-25T12:08:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,15 +1,9 @@
 # frozen_string_literal: true
 
-class Api::V1::InstancesController < Api::BaseController
-  skip_before_action :require_authenticated_user!, unless: :limited_federation_mode?
-  skip_around_action :set_locale
+class Api::V1::InstancesController < Api::V2::InstancesController
+  include DeprecationConcern
 
-  vary_by ''
-
-  # Override `current_user` to avoid reading session cookies unless in limited federation mode
-  def current_user
-    super if limited_federation_mode?
-  end
path: app/controllers/api/v1/instances_controller.rb
user: login: oneiros
body: Thanks for clarifying. I missed the changed inheritance. Now it makes sense, though it seems a bit unusual.

However, given it is deprecated anyway, I would not spend much more time (or thought) on this.
created_at: 2025-03-25T12:15:51Z
author_association: CONTRIBUTOR

PR comments:
user: login: renchap
created_at: 2025-03-25T16:11:04Z
body: We need a documentation update with this, that probably also suggests to app developers that they should look for this header and display warnings when running their app in development environment or something similar.
author_association: MEMBER
user: login: ClearlyClaire
created_at: 2025-03-27T10:26:12Z
body: Documentation PR: https://github.com/mastodon/documentation/pull/1625
author_association: CONTRIBUTOR