From 6fb356376cfa6a843c295bf64ce858036df4a550 Mon Sep 17 00:00:00 2001 From: Carlos Rodriguez Date: Fri, 6 Jun 2025 17:14:13 -0700 Subject: [PATCH] Add support for ingesting etag data from API endpoints Most of the YouTube API endpoints that return resources expose an etag property. It is highly desirable for consumers to be able to have access to this property in order to perform conditional requests (using If-Match / If-None-Match) or to verify that resources have changed. --- lib/yt/actions/list.rb | 15 ++- lib/yt/collections/assets.rb | 2 +- lib/yt/collections/playlist_items.rb | 4 +- lib/yt/collections/resources.rb | 2 +- lib/yt/models/playlist.rb | 5 - lib/yt/models/playlist_item.rb | 5 - lib/yt/models/resource.rb | 27 +++++ lib/yt/models/video.rb | 5 - spec/collections/channels_spec.rb | 20 ++++ spec/collections/comment_threads_spec.rb | 15 +++ spec/collections/playlist_items_spec.rb | 17 ++- spec/collections/playlists_spec.rb | 17 ++- spec/collections/subscriptions_spec.rb | 19 +++- spec/collections/videos_spec.rb | 15 +++ spec/models/channel_spec.rb | 7 ++ spec/models/playlist_spec.rb | 41 +++++++ spec/models/video_spec.rb | 42 ++++++++ spec/requests/as_account/etag_spec.rb | 130 +++++++++++++++++++++++ spec/requests/as_server_app/etag_spec.rb | 31 ++++++ 19 files changed, 396 insertions(+), 23 deletions(-) create mode 100644 spec/collections/channels_spec.rb create mode 100644 spec/requests/as_account/etag_spec.rb create mode 100644 spec/requests/as_server_app/etag_spec.rb diff --git a/lib/yt/actions/list.rb b/lib/yt/actions/list.rb index 1b81accf..2538179b 100644 --- a/lib/yt/actions/list.rb +++ b/lib/yt/actions/list.rb @@ -12,15 +12,22 @@ def first! first.tap{|item| raise Errors::NoItems, error_message unless item} end + def etag + @etag ||= fetch_etag + end + private def list + owner = self @last_index, @page_token = 0, nil Enumerator.new(-> {total_results}) do |items| while next_item = find_next items << next_item end @where_params = {} + end.tap do |enum| + enum.define_singleton_method(:etag) { owner.instance_variable_get(:@etag) } end end @@ -63,7 +70,7 @@ def resource_class # Can be overwritten by subclasses that initialize instance with # a different set of parameters. def new_item(data) - resource_class.new attributes_for_new_item(data) + resource_class.new attributes_for_new_item(data).merge(etag: data['etag']) end # @private @@ -91,11 +98,17 @@ def eager_load_items_from(items) def fetch_page(params = {}) @last_response = list_request(params).run + @etag = @last_response.body['etag'] token = @last_response.body['nextPageToken'] items = extract_items @last_response.body {items: items, token: token} end + def fetch_etag + response = list_request(list_params).run + response.body['etag'] + end + def list_request(params = {}) @list_request = Yt::Request.new(params).tap do |request| print "#{request.as_curl}\n" if Yt.configuration.developing? diff --git a/lib/yt/collections/assets.rb b/lib/yt/collections/assets.rb index 28108771..83ebf4bf 100644 --- a/lib/yt/collections/assets.rb +++ b/lib/yt/collections/assets.rb @@ -17,7 +17,7 @@ def insert(attributes = {}) def new_item(data) klass = (data["kind"] == "youtubePartner#assetSnippet") ? Yt::AssetSnippet : Yt::Asset - klass.new attributes_for_new_item(data) + klass.new attributes_for_new_item(data).merge(etag: data['etag']) end # @return [Hash] the parameters to submit to YouTube to list assets diff --git a/lib/yt/collections/playlist_items.rb b/lib/yt/collections/playlist_items.rb index e2231665..d8dcbc7d 100644 --- a/lib/yt/collections/playlist_items.rb +++ b/lib/yt/collections/playlist_items.rb @@ -42,7 +42,9 @@ def list_params end def playlist_items_params - resources_params.merge playlist_id: @parent.id + params = resources_params + params.merge!(playlist_id: @parent.id) if @parent + apply_where_params! params end def insert_parts diff --git a/lib/yt/collections/resources.rb b/lib/yt/collections/resources.rb index 4c9a6d34..11a1a7ba 100644 --- a/lib/yt/collections/resources.rb +++ b/lib/yt/collections/resources.rb @@ -19,7 +19,7 @@ def insert(attributes = {}, options = {}) # private def attributes_for_new_item(data) - {id: data['id'], snippet: data['snippet'], status: data['status'], auth: @auth} + {id: data['id'], snippet: data['snippet'], status: data['status'], auth: @auth, etag: data['etag']} end def resources_params diff --git a/lib/yt/models/playlist.rb b/lib/yt/models/playlist.rb index 68e545ca..d0a5e249 100644 --- a/lib/yt/models/playlist.rb +++ b/lib/yt/models/playlist.rb @@ -209,11 +209,6 @@ def reports_params end end - # @private - def exists? - !@id.nil? - end - private # @see https://developers.google.com/youtube/v3/docs/playlists/update diff --git a/lib/yt/models/playlist_item.rb b/lib/yt/models/playlist_item.rb index 3624731e..fec7ba44 100644 --- a/lib/yt/models/playlist_item.rb +++ b/lib/yt/models/playlist_item.rb @@ -83,11 +83,6 @@ def video ### PRIVATE API ### - # @private - def exists? - !@id.nil? - end - # @private # Override Resource's new to set video if the response includes it def initialize(options = {}) diff --git a/lib/yt/models/resource.rb b/lib/yt/models/resource.rb index b075891a..24dfdf9c 100644 --- a/lib/yt/models/resource.rb +++ b/lib/yt/models/resource.rb @@ -20,6 +20,19 @@ def id end end + ### ETAG ### + def etag + return nil unless exists? + + @etag ||= fetch_etag + end + + ### EXISTS? ### + + def exists? + !@id.nil? + end + ### STATUS ### has_one :status @@ -56,6 +69,7 @@ def initialize(options = {}) @id = options[:id] end @auth = options[:auth] + @etag = options[:etag] @snippet = Snippet.new(data: options[:snippet]) if options[:snippet] @status = Status.new(data: options[:status]) if options[:status] end @@ -139,6 +153,19 @@ def fetch_channel_id end end + def fetch_etag + return nil if @id.nil? + + collection = resource_collection.new(auth: @auth).where(id: @id)&.first + collection&.etag + end + + def resource_collection + name = self.class.to_s.demodulize.pluralize + require "yt/collections/#{name.underscore}" + "Yt::Collections::#{name}".constantize + end + # Since YouTube API only returns tags on Videos#list, the memoized # `@snippet` is erased if the video was instantiated through Video#search # (e.g., by calling account.videos or channel.videos), so that the full diff --git a/lib/yt/models/video.rb b/lib/yt/models/video.rb index 630ef9d2..a162cae5 100644 --- a/lib/yt/models/video.rb +++ b/lib/yt/models/video.rb @@ -635,11 +635,6 @@ def initialize(options = {}) end end - # @private - def exists? - !@id.nil? - end - # @private # Tells `has_reports` to retrieve the reports from YouTube Analytics API # either as a Channel or as a Content Owner. diff --git a/spec/collections/channels_spec.rb b/spec/collections/channels_spec.rb new file mode 100644 index 00000000..2f0429f2 --- /dev/null +++ b/spec/collections/channels_spec.rb @@ -0,0 +1,20 @@ +require 'spec_helper' +require 'yt/collections/channels' + +describe Yt::Collections::Channels do + subject(:collection) { Yt::Collections::Channels.new } + + describe '#etag' do + let(:etag) { 'etag123' } + + before do + expect_any_instance_of(Yt::Request).to receive(:run).once do + double(body: {'etag'=> etag, 'items'=> [], 'pageInfo'=> {'totalResults'=>0}}) + end + end + + it 'returns the etag from the list response' do + expect(collection.etag).to eq etag + end + end +end diff --git a/spec/collections/comment_threads_spec.rb b/spec/collections/comment_threads_spec.rb index ea67be48..93cd4d04 100644 --- a/spec/collections/comment_threads_spec.rb +++ b/spec/collections/comment_threads_spec.rb @@ -43,4 +43,19 @@ end end end + + describe '#etag' do + let(:parent) { Yt::Video.new id: 'any-id' } + let(:etag) { 'etag123' } + + before do + expect_any_instance_of(Yt::Request).to receive(:run).once do + double(body: {'etag'=> etag, 'items'=> [], 'pageInfo'=> {'totalResults'=>0}}) + end + end + + it 'returns the etag from the list response' do + expect(collection.etag).to eq etag + end + end end diff --git a/spec/collections/playlist_items_spec.rb b/spec/collections/playlist_items_spec.rb index d7f6ed58..f77c82eb 100644 --- a/spec/collections/playlist_items_spec.rb +++ b/spec/collections/playlist_items_spec.rb @@ -41,4 +41,19 @@ it { expect(collection.delete_all).to eq [true] } end -end \ No newline at end of file + + describe '#etag' do + let(:etag) { 'etag123' } + let(:behave) { receive(:fetch_etag).and_call_original } + + before do + expect_any_instance_of(Yt::Request).to receive(:run).once do + double(body: {'etag'=> etag, 'items'=> [], 'pageInfo'=> {'totalResults'=>0}}) + end + end + + it 'returns the etag from the list response' do + expect(collection.etag).to eq etag + end + end +end diff --git a/spec/collections/playlists_spec.rb b/spec/collections/playlists_spec.rb index 53a7d21b..9ba28c8a 100644 --- a/spec/collections/playlists_spec.rb +++ b/spec/collections/playlists_spec.rb @@ -24,4 +24,19 @@ it { expect(collection.delete_all).to eq [true] } end -end \ No newline at end of file + + describe '#etag' do + let(:etag) { 'etag123' } + let(:behave) { receive(:fetch_etag).and_call_original } + + before do + expect_any_instance_of(Yt::Request).to receive(:run).once do + double(body: {'etag'=> etag, 'items'=> [], 'pageInfo'=> {'totalResults'=>0}}) + end + end + + it 'returns the etag from the list response' do + expect(collection.etag).to eq etag + end + end +end diff --git a/spec/collections/subscriptions_spec.rb b/spec/collections/subscriptions_spec.rb index 23c36c2d..a23c643c 100644 --- a/spec/collections/subscriptions_spec.rb +++ b/spec/collections/subscriptions_spec.rb @@ -2,7 +2,7 @@ require 'yt/collections/subscriptions' describe Yt::Collections::Subscriptions do - subject(:collection) { Yt::Collections::Subscriptions.new } + subject(:collection) { Yt::Collections::Subscriptions.new parent: Yt::Channel.new(id: 'any-id') } let(:msg) { {response_body: {error: {errors: [{reason: reason}]}}}.to_json } before { expect(collection).to behave } @@ -22,4 +22,19 @@ it { expect{collection.insert ignore_errors: true}.not_to fail } end end -end \ No newline at end of file + + describe '#etag' do + let(:etag) { 'etag123' } + let(:behave) { receive(:fetch_etag).and_call_original } + + before do + expect_any_instance_of(Yt::Request).to receive(:run).once do + double(body: {'etag'=> etag, 'items'=> [], 'pageInfo'=> {'totalResults'=>0}}) + end + end + + it 'returns the etag from the list response' do + expect(collection.etag).to eq etag + end + end +end diff --git a/spec/collections/videos_spec.rb b/spec/collections/videos_spec.rb index f7f0ab54..000ef096 100644 --- a/spec/collections/videos_spec.rb +++ b/spec/collections/videos_spec.rb @@ -40,4 +40,19 @@ end end end + + describe '#etag' do + let(:etag) { 'etag123' } + + before do + expect_any_instance_of(Yt::Request).to receive(:run).once do + double(body: {'etag'=> etag, 'items'=> [], 'pageInfo'=> {'totalResults'=>0}}) + end + end + + it 'returns the etag from the list response' do + collection.count + expect(collection.etag).to eq etag + end + end end diff --git a/spec/models/channel_spec.rb b/spec/models/channel_spec.rb index e0c3ae06..1dac7174 100644 --- a/spec/models/channel_spec.rb +++ b/spec/models/channel_spec.rb @@ -4,6 +4,13 @@ describe Yt::Channel do subject(:channel) { Yt::Channel.new attrs } + describe '#etag' do + context 'given the API response includes an etag' do + let(:attrs) { {id: 'any-id', etag: '12345'} } + it { expect(channel.etag).to eq '12345' } + end + end + describe '#title' do context 'given a snippet with a title' do let(:attrs) { {snippet: {"title"=>"Fullscreen"}} } diff --git a/spec/models/playlist_spec.rb b/spec/models/playlist_spec.rb index 0435566e..60e033bc 100644 --- a/spec/models/playlist_spec.rb +++ b/spec/models/playlist_spec.rb @@ -4,6 +4,47 @@ describe Yt::Playlist do subject(:playlist) { Yt::Playlist.new attrs } + describe '#etag' do + context 'given the API response includes an etag' do + let(:attrs) { {id: 'any-id', etag: 'etag123'} } + it { expect(playlist.etag).to eq 'etag123' } + end + + context 'given only an ID is provided' do + let(:attrs) { {id: 'any-id', auth: double('auth')} } + let(:etag) { 'etag123' } + + before do + expect_any_instance_of(Yt::Request).to receive(:run).once do + double(body: {'etag' => etag, 'items'=> [{'id'=> 'any-id', 'etag'=> etag}], 'pageInfo'=> {'totalResults'=> 1}}) + end + end + + it 'fetches the etag from YouTube' do + expect(playlist.etag).to eq etag + end + end + + context 'given an ID is provided but no auth' do + let(:attrs) { {id: 'any-id'} } + + it 'returns the etag from the API response even if no items are found' do + expect_any_instance_of(Yt::Request).to receive(:run).once do + double(body: {'etag' => 'mockedEtag', 'items'=> [], 'pageInfo'=> {'totalResults'=> 0}}) + end + expect(playlist.etag).to eq 'mockedEtag' + end + end + + context 'given no ID is provided' do + let(:attrs) { {} } + + it 'returns nil' do + expect_any_instance_of(Yt::Request).not_to receive(:run) + expect(playlist.etag).to be_nil + end + end + end describe '#title' do context 'given a snippet with a title' do diff --git a/spec/models/video_spec.rb b/spec/models/video_spec.rb index 537940c5..f4cf4476 100644 --- a/spec/models/video_spec.rb +++ b/spec/models/video_spec.rb @@ -4,6 +4,48 @@ describe Yt::Video do subject(:video) { Yt::Video.new attrs } + describe '#etag' do + context 'given the API response includes an etag' do + let(:attrs) { {id: 'any-id', etag: '12345'} } + it { expect(video.etag).to eq '12345' } + end + + context 'given only an ID is provided' do + let(:attrs) { {id: 'any-id', auth: double('auth')} } + let(:etag) { 'etag123' } + + before do + expect_any_instance_of(Yt::Request).to receive(:run).once do + double(body: {'etag' => etag, 'items'=> [{'id'=> 'any-id', 'etag'=> etag}], 'pageInfo'=> {'totalResults'=> 1}}) + end + end + + it 'fetches the etag from YouTube' do + expect(video.etag).to eq etag + end + end + + context 'given an ID is provided but no auth' do + let(:attrs) { {id: 'any-id'} } + + it 'returns the etag from the API response even if no items are found' do + expect_any_instance_of(Yt::Request).to receive(:run).once do + double(body: {'etag' => 'mockedEtag', 'items'=> [], 'pageInfo'=> {'totalResults'=> 0}}) + end + expect(video.etag).to eq 'mockedEtag' + end + end + + context 'given no ID is provided' do + let(:attrs) { {} } + + it 'returns nil' do + expect_any_instance_of(Yt::Request).not_to receive(:run) + expect(video.etag).to be_nil + end + end + end + describe '#snippet' do context 'given fetching a video returns a snippet' do let(:attrs) { {snippet: {"title"=>"Fullscreen Creator Platform"}} } diff --git a/spec/requests/as_account/etag_spec.rb b/spec/requests/as_account/etag_spec.rb new file mode 100644 index 00000000..9ca64919 --- /dev/null +++ b/spec/requests/as_account/etag_spec.rb @@ -0,0 +1,130 @@ +# encoding: UTF-8 + +require 'spec_helper' +require 'yt/models/video' +require 'yt/models/playlist' +require 'yt/models/channel' + +describe 'Etag Integration Tests', :device_app, :vcr do + describe 'Video etag' do + context 'given a real YouTube video' do + let(:video) { Yt::Video.new id: '9bZkp7q19f0', auth: test_account } + + it 'returns a valid etag' do + expect(video.etag).to be_a String + expect(video.etag).not_to be_empty + expect(video.etag).to match(/^[A-Za-z0-9_-]+$/) + end + + it 'returns the same etag on subsequent calls' do + first_etag = video.etag + second_etag = video.etag + expect(first_etag).to eq second_etag + end + end + + context 'given a video from my account' do + let(:video) { test_account.videos.first } + + it 'returns a valid etag' do + expect(video.etag).to be_a String + expect(video.etag).not_to be_empty + expect(video.etag).to match(/^[A-Za-z0-9_-]+$/) + end + end + end + + describe 'Playlist etag' do + context 'given a real YouTube playlist' do + let(:playlist) { Yt::Playlist.new id: 'PLSWYkYzOr', auth: test_account } + + it 'returns a valid etag' do + expect(playlist.etag).to be_a String + expect(playlist.etag).not_to be_empty + expect(playlist.etag).to match(/^[A-Za-z0-9_-]+$/) + end + + it 'returns the same etag on subsequent calls' do + first_etag = playlist.etag + second_etag = playlist.etag + expect(first_etag).to eq second_etag + end + end + + context 'given a playlist from my account' do + let(:playlist) { test_account.playlists.first } + + it 'returns a valid etag' do + expect(playlist.etag).to be_a String + expect(playlist.etag).not_to be_empty + expect(playlist.etag).to match(/^[A-Za-z0-9_-]+$/) + end + end + end + + describe 'Channel etag' do + context 'given a real YouTube channel' do + let(:channel) { Yt::Channel.new id: 'UCxO1tY8h1AhOz0T4ENwmpow', auth: test_account } + + it 'returns a valid etag' do + expect(channel.etag).to be_a String + expect(channel.etag).not_to be_empty + expect(channel.etag).to match(/^[A-Za-z0-9_-]+$/) + end + + it 'returns the same etag on subsequent calls' do + first_etag = channel.etag + second_etag = channel.etag + expect(first_etag).to eq second_etag + end + end + + context 'given my own channel' do + let(:channel) { test_account.channel } + + it 'returns a valid etag' do + expect(channel.etag).to be_a String + expect(channel.etag).not_to be_empty + expect(channel.etag).to match(/^[A-Za-z0-9_-]+$/) + end + end + end + + describe 'Collection etags' do + context 'given videos collection' do + let(:videos) { test_account.videos } + + it 'returns a valid etag' do + expect(videos.etag).to be_a String + expect(videos.etag).not_to be_empty + expect(videos.etag).to match(/^[A-Za-z0-9_-]+$/) + end + + it 'returns the same etag on subsequent calls' do + first_etag = videos.etag + second_etag = videos.etag + expect(first_etag).to eq second_etag + end + end + + context 'given playlists collection' do + let(:playlists) { test_account.playlists } + + it 'returns a valid etag' do + expect(playlists.etag).to be_a String + expect(playlists.etag).not_to be_empty + expect(playlists.etag).to match(/^[A-Za-z0-9_-]+$/) + end + end + + context 'given subscribed channels collection' do + let(:subscribed_channels) { test_account.subscribed_channels } + + it 'returns a valid etag' do + expect(subscribed_channels.etag).to be_a String + expect(subscribed_channels.etag).not_to be_empty + expect(subscribed_channels.etag).to match(/^[A-Za-z0-9_-]+$/) + end + end + end +end \ No newline at end of file diff --git a/spec/requests/as_server_app/etag_spec.rb b/spec/requests/as_server_app/etag_spec.rb new file mode 100644 index 00000000..821b21b1 --- /dev/null +++ b/spec/requests/as_server_app/etag_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'yt/models/channel' +require 'yt/models/playlist' +require 'yt/models/video' + +describe 'Etag support', server_app: true do + context 'when making API requests' do + it 'returns etag for public channel' do + VCR.use_cassette('etag_public_channel') do + channel = Yt::Channel.new(id: 'UCJkWoS4RsldA1coEIot5yDA') # MotherGooseClub + expect(channel.etag).to be_present + end + end + + it 'returns etag for public playlist' do + VCR.use_cassette('etag_public_playlist') do + playlist = Yt::Playlist.new(id: 'PLgnDMw6xI5plOXaKs5zNDB3zRWYEa8Zi-') # Valid public playlist from existing tests + expect(playlist.etag).to be_present + end + end + + it 'returns etag for public video' do + VCR.use_cassette('etag_public_video') do + video = Yt::Video.new(id: '9bZkp7q19f0') # PSY - GANGNAM STYLE + expect(video.etag).to be_present + end + end + end +end \ No newline at end of file