From a9cfddf28ef139fd8a431ec3dba93a03996e12b4 Mon Sep 17 00:00:00 2001 From: David Roetzel Date: Mon, 12 Jan 2026 09:39:25 +0100 Subject: [PATCH] AP/AS serialization of Collections (#37434) --- app/lib/activitypub/tag_manager.rb | 2 + app/models/collection.rb | 4 ++ .../featured_collection_serializer.rb | 54 +++++++++++++++++++ config/routes.rb | 1 + spec/fabricators/collection_fabricator.rb | 7 +++ spec/lib/activitypub/tag_manager_spec.rb | 17 ++++++ spec/models/collection_spec.rb | 6 +++ .../featured_collection_serializer_spec.rb | 48 +++++++++++++++++ 8 files changed, 139 insertions(+) create mode 100644 app/serializers/activitypub/featured_collection_serializer.rb create mode 100644 spec/serializers/activitypub/featured_collection_serializer_spec.rb diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb index 3174d1792e..9f01b00578 100644 --- a/app/lib/activitypub/tag_manager.rb +++ b/app/lib/activitypub/tag_manager.rb @@ -62,6 +62,8 @@ class ActivityPub::TagManager emoji_url(target) when :flag target.uri + when :featured_collection + ap_account_featured_collection_url(target.account.id, target) end end diff --git a/app/models/collection.rb b/app/models/collection.rb index 2e352cbe87..b732a3d220 100644 --- a/app/models/collection.rb +++ b/app/models/collection.rb @@ -60,6 +60,10 @@ class Collection < ApplicationRecord self.tag = Tag.find_or_create_by_names(new_name).first end + def object_type + :featured_collection + end + private def tag_is_usable diff --git a/app/serializers/activitypub/featured_collection_serializer.rb b/app/serializers/activitypub/featured_collection_serializer.rb new file mode 100644 index 0000000000..e70d155a1a --- /dev/null +++ b/app/serializers/activitypub/featured_collection_serializer.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +class ActivityPub::FeaturedCollectionSerializer < ActivityPub::Serializer + class FeaturedItemSerializer < ActivityPub::Serializer + attributes :type, :featured_object, :featured_object_type + + def type + 'FeaturedItem' + end + + def featured_object + ActivityPub::TagManager.instance.uri_for(object.account) + end + + def featured_object_type + object.account.actor_type || 'Person' + end + end + + attributes :id, :type, :total_items, :name, :summary, :attributed_to, + :sensitive, :discoverable, :published, :updated + + has_one :tag, key: :topic, serializer: ActivityPub::NoteSerializer::TagSerializer + + has_many :collection_items, key: :ordered_items, serializer: FeaturedItemSerializer + + def id + ActivityPub::TagManager.instance.uri_for(object) + end + + def type + 'FeaturedCollection' + end + + def summary + object.description + end + + def attributed_to + ActivityPub::TagManager.instance.uri_for(object.account) + end + + def total_items + object.collection_items.size + end + + def published + object.created_at.iso8601 + end + + def updated + object.updated_at.iso8601 + end +end diff --git a/config/routes.rb b/config/routes.rb index 3685e695f9..ff79c758fb 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -96,6 +96,7 @@ Rails.application.routes.draw do get '/authorize_follow', to: redirect { |_, request| "/authorize_interaction?#{request.params.to_query}" } concern :account_resources do + resources :featured_collections, only: [:show] resources :followers, only: [:index], controller: :follower_accounts resources :following, only: [:index], controller: :following_accounts diff --git a/spec/fabricators/collection_fabricator.rb b/spec/fabricators/collection_fabricator.rb index a6a8411ba0..7e0e14a765 100644 --- a/spec/fabricators/collection_fabricator.rb +++ b/spec/fabricators/collection_fabricator.rb @@ -8,3 +8,10 @@ Fabricator(:collection) do sensitive false discoverable true end + +Fabricator(:remote_collection, from: :collection) do + account { Fabricate.build(:remote_account) } + local false + uri { sequence(:uri) { |i| "https://example.com/collections/#{i}" } } + original_number_of_items 0 +end diff --git a/spec/lib/activitypub/tag_manager_spec.rb b/spec/lib/activitypub/tag_manager_spec.rb index 6cbb58055e..bacbb3251c 100644 --- a/spec/lib/activitypub/tag_manager_spec.rb +++ b/spec/lib/activitypub/tag_manager_spec.rb @@ -192,6 +192,23 @@ RSpec.describe ActivityPub::TagManager do expect(subject.uri_for(status.conversation)).to eq status.conversation.uri end end + + context 'with a local collection' do + let(:collection) { Fabricate(:collection) } + + it 'returns a string starting with web domain and with the expected path' do + expect(subject.uri_for(collection)) + .to eq("#{host_prefix}/ap/users/#{collection.account.id}/featured_collections/#{collection.id}") + end + end + + context 'with a remote collection' do + let(:collection) { Fabricate(:remote_collection) } + + it 'returns the expected URL' do + expect(subject.uri_for(collection)).to eq collection.uri + end + end end describe '#key_uri_for' do diff --git a/spec/models/collection_spec.rb b/spec/models/collection_spec.rb index 659e017869..bcc31fd087 100644 --- a/spec/models/collection_spec.rb +++ b/spec/models/collection_spec.rb @@ -126,4 +126,10 @@ RSpec.describe Collection do end end end + + describe '#object_type' do + it 'returns `:featured_collection`' do + expect(subject.object_type).to eq :featured_collection + end + end end diff --git a/spec/serializers/activitypub/featured_collection_serializer_spec.rb b/spec/serializers/activitypub/featured_collection_serializer_spec.rb new file mode 100644 index 0000000000..b01cce12d8 --- /dev/null +++ b/spec/serializers/activitypub/featured_collection_serializer_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ActivityPub::FeaturedCollectionSerializer do + subject { serialized_record_json(collection, described_class, adapter: ActivityPub::Adapter) } + + let(:collection) do + Fabricate(:collection, + name: 'Incredible people', + description: 'These are really amazing', + tag_name: '#people', + discoverable: false) + end + let!(:collection_items) { Fabricate.times(2, :collection_item, collection:) } + + it 'serializes to the expected structure' do + expect(subject).to include({ + 'type' => 'FeaturedCollection', + 'id' => ActivityPub::TagManager.instance.uri_for(collection), + 'name' => 'Incredible people', + 'summary' => 'These are really amazing', + 'attributedTo' => ActivityPub::TagManager.instance.uri_for(collection.account), + 'sensitive' => false, + 'discoverable' => false, + 'topic' => { + 'href' => match(%r{/tags/people$}), + 'type' => 'Hashtag', + 'name' => '#people', + }, + 'totalItems' => 2, + 'orderedItems' => [ + { + 'type' => 'FeaturedItem', + 'featuredObject' => ActivityPub::TagManager.instance.uri_for(collection_items.first.account), + 'featuredObjectType' => 'Person', + }, + { + 'type' => 'FeaturedItem', + 'featuredObject' => ActivityPub::TagManager.instance.uri_for(collection_items.last.account), + 'featuredObjectType' => 'Person', + }, + ], + 'published' => match_api_datetime_format, + 'updated' => match_api_datetime_format, + }) + end +end