From 8625721805fdc78ebb147972978a5fc79af2261e Mon Sep 17 00:00:00 2001 From: David Roetzel Date: Mon, 8 Dec 2025 09:56:13 +0100 Subject: [PATCH] Draft API to get all collections by an account (#37139) --- .../api/v1_alpha/collections_controller.rb | 46 ++++++++++++++++ app/models/collection.rb | 5 ++ .../rest/base_collection_serializer.rb | 12 ++++ app/serializers/rest/collection_serializer.rb | 6 +- config/routes/api.rb | 4 ++ .../requests/api/v1_alpha/collections_spec.rb | 52 ++++++++++++++++++ .../rest/base_collection_serializer_spec.rb | 55 +++++++++++++++++++ 7 files changed, 175 insertions(+), 5 deletions(-) create mode 100644 app/serializers/rest/base_collection_serializer.rb create mode 100644 spec/serializers/rest/base_collection_serializer_spec.rb diff --git a/app/controllers/api/v1_alpha/collections_controller.rb b/app/controllers/api/v1_alpha/collections_controller.rb index e385822c42..b6bb462a4b 100644 --- a/app/controllers/api/v1_alpha/collections_controller.rb +++ b/app/controllers/api/v1_alpha/collections_controller.rb @@ -3,20 +3,34 @@ class Api::V1Alpha::CollectionsController < Api::BaseController include Authorization + DEFAULT_COLLECTIONS_LIMIT = 40 + rescue_from ActiveRecord::RecordInvalid, Mastodon::ValidationError do |e| render json: { error: ValidationErrorFormatter.new(e).as_json }, status: 422 end before_action :check_feature_enabled + before_action -> { authorize_if_got_token! :read, :'read:collections' }, only: [:index, :show] before_action -> { doorkeeper_authorize! :write, :'write:collections' }, only: [:create, :update, :destroy] before_action :require_user!, only: [:create, :update, :destroy] + before_action :set_account, only: [:index] + before_action :set_collections, only: [:index] before_action :set_collection, only: [:show, :update, :destroy] + after_action :insert_pagination_headers, only: [:index] + after_action :verify_authorized + def index + cache_if_unauthenticated! + authorize Collection, :index? + + render json: @collections, each_serializer: REST::BaseCollectionSerializer + end + def show cache_if_unauthenticated! authorize @collection, :show? @@ -50,6 +64,18 @@ class Api::V1Alpha::CollectionsController < Api::BaseController private + def set_account + @account = Account.find(params[:account_id]) + end + + def set_collections + @collections = @account.collections + .with_item_count + .order(created_at: :desc) + .offset(offset_param) + .limit(limit_param(DEFAULT_COLLECTIONS_LIMIT)) + end + def set_collection @collection = Collection.find(params[:id]) end @@ -65,4 +91,24 @@ class Api::V1Alpha::CollectionsController < Api::BaseController def check_feature_enabled raise ActionController::RoutingError unless Mastodon::Feature.collections_enabled? end + + def next_path + return unless records_continue? + + api_v1_alpha_account_collections_url(@account, pagination_params(offset: offset_param + limit_param(DEFAULT_COLLECTIONS_LIMIT))) + end + + def prev_path + return if offset_param.zero? + + api_v1_alpha_account_collections_url(@account, pagination_params(offset: offset_param - limit_param(DEFAULT_COLLECTIONS_LIMIT))) + end + + def records_continue? + ((offset_param * limit_param(DEFAULT_COLLECTIONS_LIMIT)) + @collections.size) < @account.collections.size + end + + def offset_param + params[:offset].to_i + end end diff --git a/app/models/collection.rb b/app/models/collection.rb index 79af2967c1..4752c4cdd2 100644 --- a/app/models/collection.rb +++ b/app/models/collection.rb @@ -39,6 +39,11 @@ class Collection < ApplicationRecord validate :items_do_not_exceed_limit scope :with_items, -> { includes(:collection_items).merge(CollectionItem.with_accounts) } + scope :with_item_count, lambda { + select('collections.*, COUNT(collection_items.id)') + .left_joins(:collection_items) + .group(collections: :id) + } def remote? !local? diff --git a/app/serializers/rest/base_collection_serializer.rb b/app/serializers/rest/base_collection_serializer.rb new file mode 100644 index 0000000000..28fcec02ab --- /dev/null +++ b/app/serializers/rest/base_collection_serializer.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class REST::BaseCollectionSerializer < ActiveModel::Serializer + attributes :uri, :name, :description, :local, :sensitive, :discoverable, + :item_count, :created_at, :updated_at + + belongs_to :tag, serializer: REST::StatusSerializer::TagSerializer + + def item_count + object.respond_to?(:item_count) ? object.item_count : object.collection_items.count + end +end diff --git a/app/serializers/rest/collection_serializer.rb b/app/serializers/rest/collection_serializer.rb index 3d63e75398..ce2d8933c4 100644 --- a/app/serializers/rest/collection_serializer.rb +++ b/app/serializers/rest/collection_serializer.rb @@ -1,11 +1,7 @@ # frozen_string_literal: true -class REST::CollectionSerializer < ActiveModel::Serializer - attributes :uri, :name, :description, :local, :sensitive, :discoverable, - :created_at, :updated_at - +class REST::CollectionSerializer < REST::BaseCollectionSerializer belongs_to :account, serializer: REST::AccountSerializer - belongs_to :tag, serializer: REST::StatusSerializer::TagSerializer has_many :items, serializer: REST::CollectionItemSerializer diff --git a/config/routes/api.rb b/config/routes/api.rb index 16b0d8edf0..ad58e8744f 100644 --- a/config/routes/api.rb +++ b/config/routes/api.rb @@ -6,6 +6,10 @@ namespace :api, format: false do # Experimental JSON / REST API namespace :v1_alpha do + resources :accounts, only: [] do + resources :collections, only: [:index] + end + resources :async_refreshes, only: :show resources :collections, only: [:show, :create, :update, :destroy] diff --git a/spec/requests/api/v1_alpha/collections_spec.rb b/spec/requests/api/v1_alpha/collections_spec.rb index c0472d2f48..3921fabfde 100644 --- a/spec/requests/api/v1_alpha/collections_spec.rb +++ b/spec/requests/api/v1_alpha/collections_spec.rb @@ -5,6 +5,58 @@ require 'rails_helper' RSpec.describe 'Api::V1Alpha::Collections', feature: :collections do include_context 'with API authentication', oauth_scopes: 'read:collections write:collections' + describe 'GET /api/v1_alpha/accounts/:account_id/collections' do + subject do + get "/api/v1_alpha/accounts/#{account.id}/collections", headers: headers, params: params + end + + let(:params) { {} } + + let(:account) { Fabricate(:account) } + + before { Fabricate.times(3, :collection, account:) } + + it 'returns all collections for the given account and http success' do + subject + + expect(response).to have_http_status(200) + expect(response.parsed_body.size).to eq 3 + end + + context 'with limit param' do + let(:params) { { limit: '1' } } + + it 'returns only a single result' do + subject + + expect(response).to have_http_status(200) + expect(response.parsed_body.size).to eq 1 + + expect(response) + .to include_pagination_headers( + next: api_v1_alpha_account_collections_url(account, limit: 1, offset: 1) + ) + end + end + + context 'with limit and offset params' do + let(:params) { { limit: '1', offset: '1' } } + + it 'returns the correct result and headers' do + subject + + expect(response).to have_http_status(200) + expect(response.parsed_body.size).to eq 1 + + expect(response) + .to include_pagination_headers( + prev: api_v1_alpha_account_collections_url(account, limit: 1, offset: 0), + next: api_v1_alpha_account_collections_url(account, limit: 1, offset: 2) + ) + end + end + end + describe 'GET /api/v1_alpha/collections/:id' do subject do get "/api/v1_alpha/collections/#{collection.id}", headers: headers diff --git a/spec/serializers/rest/base_collection_serializer_spec.rb b/spec/serializers/rest/base_collection_serializer_spec.rb new file mode 100644 index 0000000000..39f853f4d0 --- /dev/null +++ b/spec/serializers/rest/base_collection_serializer_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe REST::BaseCollectionSerializer do + subject do + serialized_record_json(collection, described_class, options: { + scope: current_user, + scope_name: :current_user, + }) + end + + let(:current_user) { nil } + + let(:tag) { Fabricate(:tag, name: 'discovery') } + let(:collection) do + Fabricate(:collection, + name: 'Exquisite follows', + description: 'Always worth a follow', + local: true, + sensitive: true, + discoverable: false, + tag:) + end + + it 'includes the relevant attributes' do + expect(subject) + .to include( + 'name' => 'Exquisite follows', + 'description' => 'Always worth a follow', + 'local' => true, + 'sensitive' => true, + 'discoverable' => false, + 'tag' => a_hash_including('name' => 'discovery'), + 'created_at' => match_api_datetime_format, + 'updated_at' => match_api_datetime_format + ) + end + + describe 'Counting items' do + before do + Fabricate.times(2, :collection_item, collection:) + end + + it 'can count items on demand' do + expect(subject['item_count']).to eq 2 + end + + it 'can use precalculated counts' do + collection.define_singleton_method :item_count, -> { 8 } + + expect(subject['item_count']).to eq 8 + end + end +end