From 0ea27dd8fd830094ecd75a06e3ada2208e5d4353 Mon Sep 17 00:00:00 2001 From: Zach Norris Date: Tue, 24 Feb 2026 11:21:06 -0800 Subject: [PATCH 01/11] Remove Namespace save and delete methods The API does not support PATCH or DELETE on namespace resources. Remove these methods so they fall back to the base Resource class NotImplementedError, preventing runtime errors against the API. --- lib/pug_client/resources/namespace.rb | 43 --------- spec/pug_client/resources/namespace_spec.rb | 97 +-------------------- 2 files changed, 4 insertions(+), 136 deletions(-) diff --git a/lib/pug_client/resources/namespace.rb b/lib/pug_client/resources/namespace.rb index c4b7c11..03a60ca 100644 --- a/lib/pug_client/resources/namespace.rb +++ b/lib/pug_client/resources/namespace.rb @@ -16,10 +16,6 @@ module Resources # metadata: { labels: { env: 'prod' } } # ) # - # @example Update namespace metadata - # namespace.metadata[:labels][:status] = 'active' - # namespace.save - # # @example List videos in namespace # namespace.videos.each { |video| puts video.id } class Namespace < Resource @@ -113,28 +109,6 @@ def self.from_api_data(client, data, _options = {}) new(client: client, attributes: data) end - # Save changes to namespace - # - # Generates JSON Patch operations from tracked changes and sends to API. - # Returns true if there were no changes or save succeeded. - # - # @return [Boolean] true if saved successfully - # @raise [NetworkError] if API request fails - # @example - # namespace.metadata[:labels][:env] = 'staging' - # namespace.save # Sends JSON Patch to API - def save - return true unless changed? - - operations = generate_patch_operations - response = @client.patch("namespaces/#{id}", { data: operations }) - load_attributes(response) - clear_dirty! - true - rescue StandardError => e - raise NetworkError, e.message - end - # Reload namespace from API # # Discards any unsaved changes and reloads from API. @@ -153,23 +127,6 @@ def reload raise NetworkError, e.message end - # Delete namespace - # - # Deletes the namespace from the API and freezes the object to prevent - # further modifications. - # - # @return [Boolean] true if deleted successfully - # @raise [NetworkError] if API request fails - # @example - # namespace.delete - def delete - @client.delete("namespaces/#{id}") - freeze_resource! - true - rescue StandardError => e - raise NetworkError, e.message - end - # Get videos in this namespace (lazy enumerator) # # @param options [Hash] Optional parameters (query filters, pagination) diff --git a/spec/pug_client/resources/namespace_spec.rb b/spec/pug_client/resources/namespace_spec.rb index fd8f71e..a6b449f 100644 --- a/spec/pug_client/resources/namespace_spec.rb +++ b/spec/pug_client/resources/namespace_spec.rb @@ -202,73 +202,10 @@ describe '#save' do let(:namespace) { resource_instance } - it 'returns true when no changes' do - expect(namespace.save).to be true - end - - it 'sends JSON Patch when changes exist' do - namespace.metadata[:labels][:env] = 'staging' - - expect(client).to receive(:patch) - .with("namespaces/#{namespace_id}", { - data: [ - { - op: 'replace', - path: '/metadata/labels/env', - value: 'staging' - } - ] - }) - .and_return({ - data: { - id: namespace_id, - attributes: { - 'metadata' => { 'labels' => { 'env' => 'staging' } } - } - } - }) - - result = namespace.save - - expect(result).to be true - expect(namespace.changed?).to be false - expect(namespace.metadata[:labels][:env]).to eq('staging') - end - - it 'handles multiple changes' do - namespace.metadata[:labels][:env] = 'staging' - namespace.metadata[:labels][:new_key] = 'value' - - expect(client).to receive(:patch) - .with("namespaces/#{namespace_id}", hash_including( - data: array_including( - hash_including(op: 'replace', path: '/metadata/labels/env'), - hash_including(op: 'add', path: '/metadata/labels/newKey') - ) - )) - .and_return({ - data: { - id: namespace_id, - attributes: { - 'metadata' => { - 'labels' => { 'env' => 'staging', 'newKey' => 'value' } - } - } - } - }) - - namespace.save - end - - it 'raises NetworkError on API failure' do - namespace.metadata[:labels][:env] = 'staging' - - allow(client).to receive(:patch) - .and_raise(StandardError.new('API Error')) - + it 'raises NotImplementedError' do expect do namespace.save - end.to raise_error(PugClient::NetworkError, /API Error/) + end.to raise_error(NotImplementedError) end end @@ -310,36 +247,10 @@ describe '#delete' do let(:namespace) { resource_instance } - it 'deletes namespace from API' do - expect(client).to receive(:delete) - .with("namespaces/#{namespace_id}") - .and_return(true) - - result = namespace.delete - - expect(result).to be true - expect(namespace).to be_frozen - end - - it 'prevents modifications after deletion' do - allow(client).to receive(:delete) - .with("namespaces/#{namespace_id}") - .and_return(true) - - namespace.delete - - expect do - namespace.metadata[:labels][:test] = 'value' - end.to raise_error(PugClient::ResourceFrozenError) - end - - it 'raises NetworkError on API failure' do - allow(client).to receive(:delete) - .and_raise(StandardError.new('API Error')) - + it 'raises NotImplementedError' do expect do namespace.delete - end.to raise_error(PugClient::NetworkError, /API Error/) + end.to raise_error(NotImplementedError) end end From 633e653b7f8ecf54cce4d21490aa40678a1519af Mon Sep 17 00:00:00 2001 From: Zach Norris Date: Tue, 24 Feb 2026 11:24:04 -0800 Subject: [PATCH 02/11] Rename updated_at to modified_at in all READ_ONLY_ATTRIBUTES The API uses modifiedAt instead of updatedAt. Update all resource classes, test helpers, and specs to match. Also fix client namespace integration test that was calling the now-removed Namespace#save. --- lib/pug_client/resources/campaign.rb | 2 +- lib/pug_client/resources/live_stream.rb | 2 +- lib/pug_client/resources/namespace.rb | 2 +- lib/pug_client/resources/namespace_client.rb | 2 +- lib/pug_client/resources/playlist.rb | 2 +- lib/pug_client/resources/simulcast_target.rb | 2 +- lib/pug_client/resources/video.rb | 2 +- lib/pug_client/resources/webhook.rb | 2 +- .../client_namespace_integration_spec.rb | 21 ++++++++----------- spec/pug_client/resources/campaign_spec.rb | 10 ++++----- spec/pug_client/resources/live_stream_spec.rb | 2 +- .../resources/namespace_client_spec.rb | 10 ++++----- spec/pug_client/resources/namespace_spec.rb | 10 ++++----- spec/pug_client/resources/video_spec.rb | 2 +- spec/support/api_response_helpers.rb | 8 +++---- 15 files changed, 38 insertions(+), 41 deletions(-) diff --git a/lib/pug_client/resources/campaign.rb b/lib/pug_client/resources/campaign.rb index c9ff518..926595c 100644 --- a/lib/pug_client/resources/campaign.rb +++ b/lib/pug_client/resources/campaign.rb @@ -11,7 +11,7 @@ class Campaign < Resource READ_ONLY_ATTRIBUTES = %i[ id created_at - updated_at + modified_at version ].freeze diff --git a/lib/pug_client/resources/live_stream.rb b/lib/pug_client/resources/live_stream.rb index 1c5cd1f..fe150d6 100644 --- a/lib/pug_client/resources/live_stream.rb +++ b/lib/pug_client/resources/live_stream.rb @@ -11,7 +11,7 @@ class LiveStream < Resource READ_ONLY_ATTRIBUTES = %i[ id created_at - updated_at + modified_at started_at stream_status stream_urls diff --git a/lib/pug_client/resources/namespace.rb b/lib/pug_client/resources/namespace.rb index 03a60ca..a8f3030 100644 --- a/lib/pug_client/resources/namespace.rb +++ b/lib/pug_client/resources/namespace.rb @@ -20,7 +20,7 @@ module Resources # namespace.videos.each { |video| puts video.id } class Namespace < Resource # Attributes that cannot be modified after creation - READ_ONLY_ATTRIBUTES = %i[id created_at updated_at].freeze + READ_ONLY_ATTRIBUTES = %i[id created_at modified_at].freeze # Find namespace by ID # diff --git a/lib/pug_client/resources/namespace_client.rb b/lib/pug_client/resources/namespace_client.rb index 16bc0cf..4d1585f 100644 --- a/lib/pug_client/resources/namespace_client.rb +++ b/lib/pug_client/resources/namespace_client.rb @@ -24,7 +24,7 @@ class NamespaceClient < Resource READ_ONLY_ATTRIBUTES = %i[ id created_at - updated_at + modified_at secret ].freeze diff --git a/lib/pug_client/resources/playlist.rb b/lib/pug_client/resources/playlist.rb index 90a99b6..9ecfb94 100644 --- a/lib/pug_client/resources/playlist.rb +++ b/lib/pug_client/resources/playlist.rb @@ -10,7 +10,7 @@ class Playlist < Resource READ_ONLY_ATTRIBUTES = %i[ id created_at - updated_at + modified_at version playback ].freeze diff --git a/lib/pug_client/resources/simulcast_target.rb b/lib/pug_client/resources/simulcast_target.rb index 433762a..9eef68b 100644 --- a/lib/pug_client/resources/simulcast_target.rb +++ b/lib/pug_client/resources/simulcast_target.rb @@ -11,7 +11,7 @@ class SimulcastTarget < Resource READ_ONLY_ATTRIBUTES = %i[ id created_at - updated_at + modified_at ].freeze attr_reader :namespace_id diff --git a/lib/pug_client/resources/video.rb b/lib/pug_client/resources/video.rb index 3be64d0..f8037ed 100644 --- a/lib/pug_client/resources/video.rb +++ b/lib/pug_client/resources/video.rb @@ -25,7 +25,7 @@ module Resources class Video < Resource # Attributes that cannot be modified after creation READ_ONLY_ATTRIBUTES = %i[ - id created_at updated_at duration started_at + id created_at modified_at duration started_at renditions playback_urls thumbnail_url playback source ].freeze diff --git a/lib/pug_client/resources/webhook.rb b/lib/pug_client/resources/webhook.rb index e022d13..e6ac617 100644 --- a/lib/pug_client/resources/webhook.rb +++ b/lib/pug_client/resources/webhook.rb @@ -11,7 +11,7 @@ class Webhook < Resource READ_ONLY_ATTRIBUTES = %i[ id created_at - updated_at + modified_at ].freeze attr_reader :namespace_id diff --git a/spec/pug_client/client_namespace_integration_spec.rb b/spec/pug_client/client_namespace_integration_spec.rb index be83475..9d43e07 100644 --- a/spec/pug_client/client_namespace_integration_spec.rb +++ b/spec/pug_client/client_namespace_integration_spec.rb @@ -135,7 +135,7 @@ end describe 'namespace resource operations' do - it 'allows chaining operations' do + it 'allows creating and reloading namespaces' do # Create namespace create_response = { data: { @@ -153,10 +153,11 @@ namespace = client.create_namespace('test-namespace', metadata: { labels: { env: 'staging' } }) - # Update namespace - namespace.metadata[:labels][:status] = 'active' + # Namespace save is not supported by the API + expect { namespace.save }.to raise_error(NotImplementedError) - patch_response = { + # Reload namespace + reload_response = { data: { id: 'test-namespace', attributes: { @@ -165,15 +166,11 @@ } } - expect(client).to receive(:patch) - .with('namespaces/test-namespace', hash_including( - data: array_including( - hash_including(op: 'add', path: '/metadata/labels/status') - ) - )) - .and_return(patch_response) + expect(client).to receive(:get) + .with('namespaces/test-namespace') + .and_return(reload_response) - expect(namespace.save).to be true + namespace.reload expect(namespace.metadata[:labels][:status]).to eq('active') end end diff --git a/spec/pug_client/resources/campaign_spec.rb b/spec/pug_client/resources/campaign_spec.rb index 50ddf73..5e103b5 100644 --- a/spec/pug_client/resources/campaign_spec.rb +++ b/spec/pug_client/resources/campaign_spec.rb @@ -375,7 +375,7 @@ id: campaign_id, slug: campaign_slug, created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-02T00:00:00Z' + modified_at: '2024-01-02T00:00:00Z' } ) end @@ -392,16 +392,16 @@ end.to raise_error(PugClient::ValidationError, /read-only.*created_at/i) end - it 'prevents modification of updated_at' do + it 'prevents modification of modified_at' do expect do - campaign.updated_at = '2024-02-01T00:00:00Z' - end.to raise_error(PugClient::ValidationError, /read-only.*updated_at/i) + campaign.modified_at = '2024-02-01T00:00:00Z' + end.to raise_error(PugClient::ValidationError, /read-only.*modified_at/i) end it 'allows reading read-only attributes' do expect(campaign.id).to eq(campaign_id) expect(campaign.created_at).to eq('2024-01-01T00:00:00Z') - expect(campaign.updated_at).to eq('2024-01-02T00:00:00Z') + expect(campaign.modified_at).to eq('2024-01-02T00:00:00Z') end it 'allows modification of other attributes' do diff --git a/spec/pug_client/resources/live_stream_spec.rb b/spec/pug_client/resources/live_stream_spec.rb index a0a2b2f..3e52b9b 100644 --- a/spec/pug_client/resources/live_stream_spec.rb +++ b/spec/pug_client/resources/live_stream_spec.rb @@ -403,7 +403,7 @@ playback_urls: { hls: 'https://playback.example.com/stream.m3u8' }, thumbnails: ['https://example.com/thumb1.jpg'], created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-02T00:00:00Z' + modified_at: '2024-01-02T00:00:00Z' } ) end diff --git a/spec/pug_client/resources/namespace_client_spec.rb b/spec/pug_client/resources/namespace_client_spec.rb index 16e3c03..b4fb5e0 100644 --- a/spec/pug_client/resources/namespace_client_spec.rb +++ b/spec/pug_client/resources/namespace_client_spec.rb @@ -183,7 +183,7 @@ id: client_id, secret: client_secret, created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-02T00:00:00Z' + modified_at: '2024-01-02T00:00:00Z' } ) end @@ -206,17 +206,17 @@ end.to raise_error(PugClient::ValidationError, /read-only.*created_at/i) end - it 'prevents modification of updated_at' do + it 'prevents modification of modified_at' do expect do - namespace_client.updated_at = '2024-02-01T00:00:00Z' - end.to raise_error(PugClient::ValidationError, /read-only.*updated_at/i) + namespace_client.modified_at = '2024-02-01T00:00:00Z' + end.to raise_error(PugClient::ValidationError, /read-only.*modified_at/i) end it 'allows reading read-only attributes' do expect(namespace_client.id).to eq(client_id) expect(namespace_client.secret).to eq(client_secret) expect(namespace_client.created_at).to eq('2024-01-01T00:00:00Z') - expect(namespace_client.updated_at).to eq('2024-01-02T00:00:00Z') + expect(namespace_client.modified_at).to eq('2024-01-02T00:00:00Z') end end diff --git a/spec/pug_client/resources/namespace_spec.rb b/spec/pug_client/resources/namespace_spec.rb index a6b449f..7f78f37 100644 --- a/spec/pug_client/resources/namespace_spec.rb +++ b/spec/pug_client/resources/namespace_spec.rb @@ -193,7 +193,7 @@ attributes: { id: namespace_id, created_at: '2025-01-01T00:00:00Z', - updated_at: '2025-01-01T00:00:00Z', + modified_at: '2025-01-01T00:00:00Z', metadata: { labels: { env: 'prod' } } } ) @@ -269,16 +269,16 @@ end.to raise_error(PugClient::ValidationError, /Cannot modify read-only attribute: created_at/) end - it 'prevents modification of updated_at' do + it 'prevents modification of modified_at' do expect do - namespace.updated_at = '2025-01-02T00:00:00Z' - end.to raise_error(PugClient::ValidationError, /Cannot modify read-only attribute: updated_at/) + namespace.modified_at = '2025-01-02T00:00:00Z' + end.to raise_error(PugClient::ValidationError, /Cannot modify read-only attribute: modified_at/) end it 'allows reading read-only attributes' do expect(namespace.id).to eq(namespace_id) expect(namespace.created_at).to eq('2025-01-01T00:00:00Z') - expect(namespace.updated_at).to eq('2025-01-01T00:00:00Z') + expect(namespace.modified_at).to eq('2025-01-01T00:00:00Z') end end diff --git a/spec/pug_client/resources/video_spec.rb b/spec/pug_client/resources/video_spec.rb index 89045ff..fc018f5 100644 --- a/spec/pug_client/resources/video_spec.rb +++ b/spec/pug_client/resources/video_spec.rb @@ -554,7 +554,7 @@ attributes: { id: video_id, created_at: '2025-01-01T00:00:00Z', - updated_at: '2025-01-01T00:00:00Z', + modified_at: '2025-01-01T00:00:00Z', duration: 120_000, renditions: [{ format: 'hls' }], playback_urls: { hls: 'https://example.com/video.m3u8' }, diff --git a/spec/support/api_response_helpers.rb b/spec/support/api_response_helpers.rb index 0e05134..01e5b7f 100644 --- a/spec/support/api_response_helpers.rb +++ b/spec/support/api_response_helpers.rb @@ -77,16 +77,16 @@ def build_api_collection(type:, items: [], links: nil) # Build common metadata timestamps # # @param created_at [String] ISO8601 timestamp (default: 2024-01-01T00:00:00Z) - # @param updated_at [String] ISO8601 timestamp (default: created_at value) + # @param modified_at [String] ISO8601 timestamp (default: created_at value) # @return [Hash] Metadata hash with camelCase keys # # @example # build_metadata_timestamps - # # => { 'createdAt' => '2024-01-01T00:00:00Z', 'updatedAt' => '2024-01-01T00:00:00Z' } - def build_metadata_timestamps(created_at: '2024-01-01T00:00:00Z', updated_at: nil) + # # => { 'createdAt' => '2024-01-01T00:00:00Z', 'modifiedAt' => '2024-01-01T00:00:00Z' } + def build_metadata_timestamps(created_at: '2024-01-01T00:00:00Z', modified_at: nil) { 'createdAt' => created_at, - 'updatedAt' => updated_at || created_at + 'modifiedAt' => modified_at || created_at } end From f0bd140e0296ffe68be67ce9cda6710c2ca23471 Mon Sep 17 00:00:00 2001 From: Zach Norris Date: Tue, 24 Feb 2026 11:31:00 -0800 Subject: [PATCH 03/11] Fix LiveStream type casing to match API The API expects lowercase 'liveStreams' for the JSON:API type field, not 'LiveStreams'. --- lib/pug_client/resources/live_stream.rb | 2 +- spec/pug_client/resources/live_stream_spec.rb | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/pug_client/resources/live_stream.rb b/lib/pug_client/resources/live_stream.rb index fe150d6..b224e66 100644 --- a/lib/pug_client/resources/live_stream.rb +++ b/lib/pug_client/resources/live_stream.rb @@ -85,7 +85,7 @@ def self.create(client, namespace_id, options = {}) body = { data: { - type: 'LiveStreams', + type: 'liveStreams', attributes: attributes } } diff --git a/spec/pug_client/resources/live_stream_spec.rb b/spec/pug_client/resources/live_stream_spec.rb index 3e52b9b..c32e2bd 100644 --- a/spec/pug_client/resources/live_stream_spec.rb +++ b/spec/pug_client/resources/live_stream_spec.rb @@ -9,7 +9,7 @@ let(:api_response) do build_api_response( - type: 'LiveStreams', + type: 'liveStreams', id: livestream_id, attributes: { 'streamStatus' => 'idle', @@ -28,7 +28,7 @@ api_response = { data: { id: livestream_id, - type: 'LiveStreams', + type: 'liveStreams', attributes: { 'streamStatus' => 'idle' } @@ -39,7 +39,7 @@ "namespaces/#{namespace_id}/livestreams", { data: { - type: 'LiveStreams', + type: 'liveStreams', attributes: {} } } @@ -60,7 +60,7 @@ "namespaces/#{namespace_id}/livestreams", { data: { - type: 'LiveStreams', + type: 'liveStreams', attributes: { startedAt: '2024-01-01T12:00:00Z', metadata: { @@ -75,7 +75,7 @@ ).and_return({ data: { id: livestream_id, - type: 'LiveStreams', + type: 'liveStreams', attributes: { 'status' => 'idle' } } }) @@ -132,7 +132,7 @@ api_data = { data: { id: livestream_id, - type: 'LiveStreams', + type: 'liveStreams', attributes: { 'streamStatus' => 'active', 'createdAt' => '2024-01-01T00:00:00Z' From d9ef03db8f3a8b4b253236c1fc0fc6fc241b1911 Mon Sep 17 00:00:00 2001 From: Zach Norris Date: Tue, 24 Feb 2026 11:32:03 -0800 Subject: [PATCH 04/11] Fix Video READ_ONLY_ATTRIBUTES to match API v1.1.0 Remove started_at and source (now writable). Add playback_start and playback_stop (server-managed fields). --- lib/pug_client/resources/video.rb | 5 +++-- spec/pug_client/resources/video_spec.rb | 12 ++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/lib/pug_client/resources/video.rb b/lib/pug_client/resources/video.rb index f8037ed..4ad4603 100644 --- a/lib/pug_client/resources/video.rb +++ b/lib/pug_client/resources/video.rb @@ -25,8 +25,9 @@ module Resources class Video < Resource # Attributes that cannot be modified after creation READ_ONLY_ATTRIBUTES = %i[ - id created_at modified_at duration started_at - renditions playback_urls thumbnail_url playback source + id created_at modified_at duration + renditions playback_urls thumbnail_url playback + playback_start playback_stop ].freeze # Supported video content types for upload diff --git a/spec/pug_client/resources/video_spec.rb b/spec/pug_client/resources/video_spec.rb index fc018f5..96f9eae 100644 --- a/spec/pug_client/resources/video_spec.rb +++ b/spec/pug_client/resources/video_spec.rb @@ -581,6 +581,18 @@ end.to raise_error(PugClient::ValidationError, /Cannot modify read-only attribute: renditions/) end + it 'prevents modification of playback_start' do + expect do + video.playback_start = 1000 + end.to raise_error(PugClient::ValidationError, /Cannot modify read-only attribute: playback_start/) + end + + it 'prevents modification of playback_stop' do + expect do + video.playback_stop = 5000 + end.to raise_error(PugClient::ValidationError, /Cannot modify read-only attribute: playback_stop/) + end + it 'allows reading read-only attributes' do expect(video.id).to eq(video_id) expect(video.duration).to eq(120_000) From d5cc88a225dcc53856abef3d7eab99558248f913 Mon Sep 17 00:00:00 2001 From: Zach Norris Date: Tue, 24 Feb 2026 11:35:10 -0800 Subject: [PATCH 05/11] Remove started_at from LiveStream READ_ONLY_ATTRIBUTES The API allows started_at to be set by clients, so it should not be read-only. --- lib/pug_client/resources/live_stream.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/pug_client/resources/live_stream.rb b/lib/pug_client/resources/live_stream.rb index b224e66..cb69585 100644 --- a/lib/pug_client/resources/live_stream.rb +++ b/lib/pug_client/resources/live_stream.rb @@ -12,7 +12,6 @@ class LiveStream < Resource id created_at modified_at - started_at stream_status stream_urls playback_urls From 2c385e8b7ef2dd3c3dbd8db24777af970ea3caa1 Mon Sep 17 00:00:00 2001 From: Zach Norris Date: Tue, 24 Feb 2026 11:46:02 -0800 Subject: [PATCH 06/11] Fix Campaign field names to match API v1.1.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename preroll_video_id/postroll_video_id to preroll_id/postroll_id. Add FIELD_MAPPINGS infrastructure to Resource base class and PatchGenerator for fields where snake_case → camelCase doesn't match the API (Campaign start_time → start, end_time → end). --- lib/pug_client/client.rb | 2 +- lib/pug_client/patch_generator.rb | 15 +++--- lib/pug_client/resource.rb | 19 ++++++- lib/pug_client/resources/campaign.rb | 63 +++++++++++++--------- lib/pug_client/resources/namespace.rb | 2 +- spec/pug_client/resources/campaign_spec.rb | 36 ++++++------- 6 files changed, 85 insertions(+), 52 deletions(-) diff --git a/lib/pug_client/client.rb b/lib/pug_client/client.rb index 70e1561..d0a2223 100644 --- a/lib/pug_client/client.rb +++ b/lib/pug_client/client.rb @@ -251,7 +251,7 @@ def campaign(campaign_id, namespace: @namespace, **options) # @param name [String] Campaign display name (required, 2-256 chars) # @param slug [String] Campaign slug identifier (required, 1-32 chars, alphanumeric + dashes) # @param namespace [String] Namespace identifier (defaults to configured namespace) - # @param options [Hash] Optional parameters (preroll_video_id, postroll_video_id, + # @param options [Hash] Optional parameters (preroll_id, postroll_id, # start_time, end_time, metadata) # @return [Resources::Campaign] Created campaign resource # @example diff --git a/lib/pug_client/patch_generator.rb b/lib/pug_client/patch_generator.rb index 2a2756c..f5e5a90 100644 --- a/lib/pug_client/patch_generator.rb +++ b/lib/pug_client/patch_generator.rb @@ -24,24 +24,24 @@ module PatchGenerator # {type: :remove, path: [:metadata, :labels, :old_key]} # ] # patches = PatchGenerator.generate(changes) - def self.generate(changes) + def self.generate(changes, field_mappings: {}) changes.map do |change| case change[:type] when :add { op: 'add', - path: json_pointer(change[:path]), + path: json_pointer(change[:path], field_mappings), value: convert_value(change[:value]) } when :remove { op: 'remove', - path: json_pointer(change[:path]) + path: json_pointer(change[:path], field_mappings) } when :replace { op: 'replace', - path: json_pointer(change[:path]), + path: json_pointer(change[:path], field_mappings), value: convert_value(change[:new_value]) } end @@ -61,8 +61,11 @@ def self.generate(changes) # json_pointer([:simulcast_targets]) # # => "/simulcastTargets" # @api private - def self.json_pointer(path_array) - "/#{path_array.map { |key| AttributeTranslator.camelize(key) }.join('/')}" + def self.json_pointer(path_array, field_mappings = {}) + segments = path_array.map do |key| + field_mappings.key?(key) ? field_mappings[key] : AttributeTranslator.camelize(key) + end + "/#{segments.join('/')}" end # Convert value to API format diff --git a/lib/pug_client/resource.rb b/lib/pug_client/resource.rb index 9fac2cf..a9eb4ac 100644 --- a/lib/pug_client/resource.rb +++ b/lib/pug_client/resource.rb @@ -30,6 +30,13 @@ class Resource # Subclasses should override this to specify their read-only attributes READ_ONLY_ATTRIBUTES = [].freeze + # Maps Ruby attribute names to API field names when they differ from + # the standard snake_case → camelCase conversion. + # Subclasses should override this for fields with non-standard names. + # @example + # FIELD_MAPPINGS = { start_time: 'start', end_time: 'end' }.freeze + FIELD_MAPPINGS = {}.freeze + attr_reader :client, :id # Initialize a new resource @@ -80,6 +87,16 @@ def load_attributes(data) AttributeTranslator.from_api(data) end + # Apply reverse field mappings (e.g., API 'start' → Ruby :start_time) + reverse_mappings = self.class::FIELD_MAPPINGS.each_with_object({}) do |(ruby_name, api_name), map| + map[AttributeTranslator.underscore(api_name).to_sym] = ruby_name + end + unless reverse_mappings.empty? + parsed = parsed.each_with_object({}) do |(key, value), result| + result[reverse_mappings[key] || key] = value + end + end + # Wrap hashes in TrackedHash for dirty tracking parsed.each do |key, value| parsed[key] = wrap_value(value) @@ -156,7 +173,7 @@ def delete def generate_patch_operations return [] unless changed? - PatchGenerator.generate(changes) + PatchGenerator.generate(changes, field_mappings: self.class::FIELD_MAPPINGS) end # Freeze resource after deletion to prevent further modifications diff --git a/lib/pug_client/resources/campaign.rb b/lib/pug_client/resources/campaign.rb index 926595c..595f80a 100644 --- a/lib/pug_client/resources/campaign.rb +++ b/lib/pug_client/resources/campaign.rb @@ -15,6 +15,10 @@ class Campaign < Resource version ].freeze + # Maps Ruby attribute names to API field names for non-standard translations. + # start_time/end_time map to start/end in the API (not startTime/endTime). + FIELD_MAPPINGS = { start_time: 'start', end_time: 'end' }.freeze + attr_reader :namespace_id # Initialize a new Campaign resource @@ -68,29 +72,13 @@ def self.all(client, namespace_id, options = {}) # @param namespace_id [String] The namespace ID # @param name [String] The campaign display name (required, 2-256 chars) # @param slug [String] The campaign slug identifier (required, 1-32 chars, alphanumeric + dashes) - # @param options [Hash] Optional attributes (preroll_video_id, postroll_video_id, + # @param options [Hash] Optional attributes (preroll_id, postroll_id, # start_time, end_time, metadata) # @return [Campaign] The created campaign resource # @raise [NetworkError] If the API request fails def self.create(client, namespace_id, name, slug, options = {}) - # Convert Time objects to ISO8601 strings - options = options.dup - options[:start_time] = options[:start_time].utc.iso8601 if options[:start_time].is_a?(Time) - options[:end_time] = options[:end_time].utc.iso8601 if options[:end_time].is_a?(Time) - - # Add required fields - options[:name] = name - options[:slug] = slug - - # Convert to API format (camelCase) - attributes = AttributeTranslator.to_api(options) - - body = { - data: { - type: 'campaigns', - attributes: attributes - } - } + attributes = build_create_attributes(name, slug, options) + body = { data: { type: 'campaigns', attributes: attributes } } response = client.post("namespaces/#{namespace_id}/campaigns", body) new(client: client, namespace_id: namespace_id, attributes: response) @@ -98,6 +86,31 @@ def self.create(client, namespace_id, name, slug, options = {}) raise NetworkError, e.message end + # Build API-formatted attributes for campaign creation + # @api private + def self.build_create_attributes(name, slug, options) + options = coerce_time_fields(options.merge(name: name, slug: slug)) + mapped = apply_field_mappings(options) + AttributeTranslator.to_api(mapped) + end + + # Convert Time objects to ISO8601 strings + # @api private + def self.coerce_time_fields(options) + options = options.dup + options[:start_time] = options[:start_time].utc.iso8601 if options[:start_time].is_a?(Time) + options[:end_time] = options[:end_time].utc.iso8601 if options[:end_time].is_a?(Time) + options + end + + # Rename Ruby attribute keys to their API field names via FIELD_MAPPINGS + # @api private + def self.apply_field_mappings(hash) + hash.transform_keys { |k| FIELD_MAPPINGS.key?(k) ? FIELD_MAPPINGS[k].to_sym : k } + end + + private_class_method :build_create_attributes, :coerce_time_fields, :apply_field_mappings + # Instantiate a campaign from API response data # # @param client [PugClient::Client] The API client @@ -174,22 +187,22 @@ def slug @current_attributes[:slug] end - # Get the preroll video if preroll_video_id is set + # Get the preroll video if preroll_id is set # # @return [Video, nil] The preroll video or nil def preroll_video - return nil unless @current_attributes[:preroll_video_id] + return nil unless @current_attributes[:preroll_id] - @preroll_video ||= Video.find(@client, @namespace_id, @current_attributes[:preroll_video_id]) + @preroll_video ||= Video.find(@client, @namespace_id, @current_attributes[:preroll_id]) end - # Get the postroll video if postroll_video_id is set + # Get the postroll video if postroll_id is set # # @return [Video, nil] The postroll video or nil def postroll_video - return nil unless @current_attributes[:postroll_video_id] + return nil unless @current_attributes[:postroll_id] - @postroll_video ||= Video.find(@client, @namespace_id, @current_attributes[:postroll_video_id]) + @postroll_video ||= Video.find(@client, @namespace_id, @current_attributes[:postroll_id]) end # Human-readable representation of the campaign diff --git a/lib/pug_client/resources/namespace.rb b/lib/pug_client/resources/namespace.rb index a8f3030..263c7ce 100644 --- a/lib/pug_client/resources/namespace.rb +++ b/lib/pug_client/resources/namespace.rb @@ -185,7 +185,7 @@ def campaigns(options = {}) # # @param name [String] Campaign display name (required, 2-256 chars) # @param slug [String] Campaign slug identifier (required, 1-32 chars, alphanumeric + dashes) - # @param options [Hash] Optional parameters (preroll_video_id, postroll_video_id, + # @param options [Hash] Optional parameters (preroll_id, postroll_id, # start_time, end_time, metadata) # @return [Campaign] The created campaign # @example diff --git a/spec/pug_client/resources/campaign_spec.rb b/spec/pug_client/resources/campaign_spec.rb index 5e103b5..49819b1 100644 --- a/spec/pug_client/resources/campaign_spec.rb +++ b/spec/pug_client/resources/campaign_spec.rb @@ -100,10 +100,10 @@ attributes: { name: campaign_name, slug: campaign_slug, - prerollVideoId: 'video-123', - postrollVideoId: 'video-456', - startTime: '2024-06-01T00:00:00Z', - endTime: '2024-08-31T23:59:59Z', + prerollId: 'video-123', + postrollId: 'video-456', + start: '2024-06-01T00:00:00Z', + end: '2024-08-31T23:59:59Z', metadata: { labels: { season: 'summer' }, annotations: { description: 'Summer campaign' } @@ -127,8 +127,8 @@ namespace_id, campaign_name, campaign_slug, - preroll_video_id: 'video-123', - postroll_video_id: 'video-456', + preroll_id: 'video-123', + postroll_id: 'video-456', start_time: start_time, end_time: end_time, metadata: { @@ -151,7 +151,7 @@ attributes: hash_including( name: campaign_name, slug: campaign_slug, - startTime: '2024-01-01T12:00:00Z' + start: '2024-01-01T12:00:00Z' ) ) ) @@ -237,14 +237,14 @@ end it 'handles multiple changes' do - campaign.preroll_video_id = 'new-video-123' + campaign.preroll_id = 'new-video-123' campaign.metadata[:labels][:version] = 'v2' expect(client).to receive(:patch).with( "namespaces/#{namespace_id}/campaigns/#{campaign_slug}", hash_including( data: array_including( - hash_including(op: 'add', path: '/prerollVideoId'), + hash_including(op: 'add', path: '/prerollId'), hash_including(op: 'add', path: '/metadata/labels/version') ) ) @@ -289,14 +289,14 @@ it_behaves_like 'has namespace association' describe '#preroll_video' do - it 'fetches preroll video when preroll_video_id is set' do + it 'fetches preroll video when preroll_id is set' do campaign = described_class.new( client: client, namespace_id: namespace_id, attributes: { id: campaign_id, slug: campaign_slug, - preroll_video_id: 'video-123' + preroll_id: 'video-123' } ) @@ -316,7 +316,7 @@ expect(video.id).to eq('video-123') end - it 'returns nil when preroll_video_id is not set' do + it 'returns nil when preroll_id is not set' do campaign = described_class.new( client: client, namespace_id: namespace_id, @@ -328,14 +328,14 @@ end describe '#postroll_video' do - it 'fetches postroll video when postroll_video_id is set' do + it 'fetches postroll video when postroll_id is set' do campaign = described_class.new( client: client, namespace_id: namespace_id, attributes: { id: campaign_id, slug: campaign_slug, - postroll_video_id: 'video-456' + postroll_id: 'video-456' } ) @@ -355,7 +355,7 @@ expect(video.id).to eq('video-456') end - it 'returns nil when postroll_video_id is not set' do + it 'returns nil when postroll_id is not set' do campaign = described_class.new( client: client, namespace_id: namespace_id, @@ -405,7 +405,7 @@ end it 'allows modification of other attributes' do - expect { campaign.preroll_video_id = 'new-video' }.not_to raise_error + expect { campaign.preroll_id = 'new-video' }.not_to raise_error expect { campaign.metadata = { labels: { test: 'value' } } }.not_to raise_error end end @@ -418,13 +418,13 @@ let(:campaign) { resource_instance } it 'generates correct patch operations for multiple changes' do - campaign.preroll_video_id = 'video-789' + campaign.preroll_id = 'video-789' campaign.metadata[:labels][:version] = 'v2' operations = campaign.generate_patch_operations expect(operations).to include( - hash_including(op: 'add', path: '/prerollVideoId', value: 'video-789') + hash_including(op: 'add', path: '/prerollId', value: 'video-789') ) expect(operations).to include( hash_including(op: 'add', path: '/metadata/labels/version', value: 'v2') From 0edab848897e9a86b3d1fe3c4e5aaecb1e3b6a49 Mon Sep 17 00:00:00 2001 From: Zach Norris Date: Tue, 24 Feb 2026 11:54:28 -0800 Subject: [PATCH 07/11] Add filter support with automatic snake_case to camelCase translation Users can pass snake_case filter keys that auto-translate to camelCase for the API. Supports both top-level filter: option and nested query: { filter: ... } syntax. --- lib/pug_client/connection.rb | 4 +++- lib/pug_client/resource_enumerator.rb | 6 +++++- lib/pug_client/resources/live_stream.rb | 3 +++ lib/pug_client/resources/simulcast_target.rb | 3 +++ lib/pug_client/resources/video.rb | 5 ++++- spec/pug_client/connection_spec.rb | 13 +++++++++++++ spec/pug_client/resource_enumerator_spec.rb | 16 ++++++++++++++++ 7 files changed, 47 insertions(+), 3 deletions(-) diff --git a/lib/pug_client/connection.rb b/lib/pug_client/connection.rb index 60a51c8..2fc1840 100644 --- a/lib/pug_client/connection.rb +++ b/lib/pug_client/connection.rb @@ -323,7 +323,9 @@ def flatten_params(params, prefix = nil) result = {} params.each do |key, value| - full_key = prefix ? "#{prefix}[#{key}]" : key.to_s + # Translate filter keys from snake_case to camelCase for the API + translated_key = prefix&.start_with?('filter') ? AttributeTranslator.camelize(key) : key + full_key = prefix ? "#{prefix}[#{translated_key}]" : translated_key.to_s if value.is_a?(Hash) result.merge!(flatten_params(value, full_key)) diff --git a/lib/pug_client/resource_enumerator.rb b/lib/pug_client/resource_enumerator.rb index 4db48f4..c4a92b8 100644 --- a/lib/pug_client/resource_enumerator.rb +++ b/lib/pug_client/resource_enumerator.rb @@ -89,7 +89,11 @@ def fetch_pages(&block) url = @base_url # Build params hash to pass to client.get params = {} - params[:query] = @options[:query] if @options[:query] + params[:query] = @options[:query].dup if @options[:query] + + # Support top-level :filter convenience (auto-lift into query params) + params[:query] ||= {} + params[:query][:filter] = @options[:filter] if @options[:filter] # Set default page size (needs to be under :query key for Connection) params[:query] ||= {} diff --git a/lib/pug_client/resources/live_stream.rb b/lib/pug_client/resources/live_stream.rb index cb69585..f4cffec 100644 --- a/lib/pug_client/resources/live_stream.rb +++ b/lib/pug_client/resources/live_stream.rb @@ -56,7 +56,10 @@ def self.find(client, namespace_id, livestream_id, options = {}) # @param client [PugClient::Client] The API client # @param namespace_id [String] The namespace ID # @param options [Hash] Additional options for filtering/pagination + # @option options [Hash] :filter Filter parameters (snake_case keys auto-translate to camelCase) # @return [ResourceEnumerator] Enumerator for lazy loading livestreams + # @example With filters + # LiveStream.all(client, 'my-ns', filter: { stream_status: 'active' }) def self.all(client, namespace_id, options = {}) ResourceEnumerator.new( client: client, diff --git a/lib/pug_client/resources/simulcast_target.rb b/lib/pug_client/resources/simulcast_target.rb index 9eef68b..bce0c08 100644 --- a/lib/pug_client/resources/simulcast_target.rb +++ b/lib/pug_client/resources/simulcast_target.rb @@ -52,7 +52,10 @@ def self.find(client, namespace_id, target_id, options = {}) # @param client [PugClient::Client] The API client # @param namespace_id [String] The namespace ID # @param options [Hash] Additional options for filtering/pagination + # @option options [Hash] :filter Filter parameters (snake_case keys auto-translate to camelCase) # @return [ResourceEnumerator] Enumerator for lazy loading simulcast targets + # @example With filters + # SimulcastTarget.all(client, 'my-ns', filter: { url: 'rtmp://...' }) def self.all(client, namespace_id, options = {}) ResourceEnumerator.new( client: client, diff --git a/lib/pug_client/resources/video.rb b/lib/pug_client/resources/video.rb index 4ad4603..f259fd2 100644 --- a/lib/pug_client/resources/video.rb +++ b/lib/pug_client/resources/video.rb @@ -73,10 +73,13 @@ def self.find(client, namespace_id, video_id, options = {}) # @param client [Client] The API client # @param namespace_id [String] Namespace identifier # @param options [Hash] Optional parameters (query filters, pagination) + # @option options [Hash] :filter Filter parameters (snake_case keys auto-translate to camelCase) # @return [ResourceEnumerator] Lazy enumerator for videos - # @example + # @example Basic listing # Video.all(client, 'my-namespace').each { |v| puts v.id } # Video.all(client, 'my-namespace').first(10) + # @example With filters + # Video.all(client, 'my-namespace', filter: { category: 'highlights' }) def self.all(client, namespace_id, options = {}) ResourceEnumerator.new( client: client, diff --git a/spec/pug_client/connection_spec.rb b/spec/pug_client/connection_spec.rb index a049ff2..4eb911d 100644 --- a/spec/pug_client/connection_spec.rb +++ b/spec/pug_client/connection_spec.rb @@ -196,6 +196,19 @@ expect(stub).to have_been_requested end + it 'translates snake_case filter keys to camelCase' do + stub = stub_request(:get, 'https://staging-api.video.scorevision.com/items') + .with(query: { 'filter[streamStatus]' => 'active', 'filter[createdAt]' => '2025-01-01' }) + .to_return( + status: 200, + body: { data: [] }.to_json, + headers: { 'Content-Type' => 'application/vnd.api+json' } + ) + + client.get('items', query: { filter: { stream_status: 'active', created_at: '2025-01-01' } }) + expect(stub).to have_been_requested + end + it 'supports custom headers' do stub = stub_request(:get, 'https://staging-api.video.scorevision.com/items') .with(headers: { 'X-Custom-Header' => 'custom-value' }) diff --git a/spec/pug_client/resource_enumerator_spec.rb b/spec/pug_client/resource_enumerator_spec.rb index 32739aa..0330acc 100644 --- a/spec/pug_client/resource_enumerator_spec.rb +++ b/spec/pug_client/resource_enumerator_spec.rb @@ -138,6 +138,22 @@ def initialize(id) enumerator.to_a end + + it 'supports top-level filter option as convenience' do + allow(client).to receive(:get) do |_url, params| + expect(params[:query][:filter]).to eq({ stream_status: 'active' }) + [] + end + + enumerator = described_class.new( + client: client, + resource_class: mock_resource_class, + base_url: 'namespaces/test/livestreams', + options: { filter: { stream_status: 'active' } } + ) + + enumerator.to_a + end end describe '#first' do From 05dee2d3c232c2acfcebc43d6ff86518a54bdfb6 Mon Sep 17 00:00:00 2001 From: Zach Norris Date: Tue, 24 Feb 2026 11:57:33 -0800 Subject: [PATCH 08/11] Add webhook ACTIONS constant with all 13 event action strings Provides a discoverable list of valid webhook action strings for video, livestream, and simulcast target events. --- lib/pug_client/resources/webhook.rb | 13 +++++++++ spec/pug_client/resources/webhook_spec.rb | 32 +++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/lib/pug_client/resources/webhook.rb b/lib/pug_client/resources/webhook.rb index e6ac617..ec5ba1e 100644 --- a/lib/pug_client/resources/webhook.rb +++ b/lib/pug_client/resources/webhook.rb @@ -14,6 +14,19 @@ class Webhook < Resource modified_at ].freeze + # Supported webhook action strings for event subscriptions + # + # @example Subscribe to video events + # client.create_webhook(url, ['video.ready', 'video.deleted']) + # @example Subscribe to all livestream events + # actions = Webhook::ACTIONS.select { |a| a.start_with?('livestream.') } + ACTIONS = %w[ + video.ready video.source.uploaded video.edited video.deleted + livestream.published livestream.unpublished livestream.disabled livestream.enabled + simulcasttarget.created simulcasttarget.edited simulcasttarget.started + simulcasttarget.stopped simulcasttarget.deleted + ].freeze + attr_reader :namespace_id # Initialize a new Webhook resource diff --git a/spec/pug_client/resources/webhook_spec.rb b/spec/pug_client/resources/webhook_spec.rb index 14dcc96..936642d 100644 --- a/spec/pug_client/resources/webhook_spec.rb +++ b/spec/pug_client/resources/webhook_spec.rb @@ -327,6 +327,38 @@ end end + describe 'ACTIONS' do + it 'contains all 13 webhook action strings' do + expect(described_class::ACTIONS).to be_a(Array) + expect(described_class::ACTIONS.length).to eq(13) + end + + it 'includes video actions' do + expect(described_class::ACTIONS).to include( + 'video.ready', 'video.source.uploaded', 'video.edited', 'video.deleted' + ) + end + + it 'includes livestream actions' do + expect(described_class::ACTIONS).to include( + 'livestream.published', 'livestream.unpublished', + 'livestream.disabled', 'livestream.enabled' + ) + end + + it 'includes simulcast target actions' do + expect(described_class::ACTIONS).to include( + 'simulcasttarget.created', 'simulcasttarget.edited', + 'simulcasttarget.started', 'simulcasttarget.stopped', + 'simulcasttarget.deleted' + ) + end + + it 'is frozen' do + expect(described_class::ACTIONS).to be_frozen + end + end + describe 'dirty tracking' do let(:webhook) do described_class.new(client: client, namespace_id: namespace_id, attributes: api_response) From 1fa77b0a657e4d8022723a6a7ee08807926c39fa Mon Sep 17 00:00:00 2001 From: Zach Norris Date: Tue, 24 Feb 2026 12:00:42 -0800 Subject: [PATCH 09/11] Bump version to 1.0.0 for API v1.1.0 sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major version bump for breaking changes: removed Namespace save/delete, renamed Campaign fields (preroll_video_id → preroll_id, start_time → start), renamed updated_at → modified_at. --- CLAUDE.md | 2 +- lib/pug_client/version.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 30d64d8..cea7048 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - Dual API: module-level (singleton) and instance-level configuration - OAuth2 authentication via Auth0 client credentials flow -**Version:** 0.1.0 (not yet published) +**Version:** 1.0.0 (not yet published) **Ruby:** >= 3.4 **Main Dependencies:** Faraday (>= 2.14) diff --git a/lib/pug_client/version.rb b/lib/pug_client/version.rb index 8a98661..8962df1 100644 --- a/lib/pug_client/version.rb +++ b/lib/pug_client/version.rb @@ -2,5 +2,5 @@ module PugClient # Current version of the PugClient gem - VERSION = '0.1.3' + VERSION = '1.0.0' end From 2892117c259a34c646179f2a99d025de0c3fdfd2 Mon Sep 17 00:00:00 2001 From: Zach Norris Date: Tue, 24 Feb 2026 15:38:06 -0800 Subject: [PATCH 10/11] Preserve user-defined metadata label and annotation key casing and update Gemfile.lock for v1.0.0 --- Gemfile.lock | 2 +- lib/pug_client/attribute_translator.rb | 24 ++++++++++++++----- lib/pug_client/patch_generator.rb | 9 ++++--- spec/integration/videos_spec.rb | 4 +++- spec/pug_client/attribute_translator_spec.rb | 23 +++++++++--------- spec/pug_client/patch_generator_spec.rb | 9 +++---- spec/pug_client/resources/live_stream_spec.rb | 2 +- 7 files changed, 46 insertions(+), 27 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 0796e9a..8c7483a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - pug-client (0.1.3) + pug-client (1.0.0) faraday (>= 2.14, < 3) GEM diff --git a/lib/pug_client/attribute_translator.rb b/lib/pug_client/attribute_translator.rb index 5eb1e84..7f545ee 100644 --- a/lib/pug_client/attribute_translator.rb +++ b/lib/pug_client/attribute_translator.rb @@ -93,25 +93,37 @@ def self.camelize(string) end.join end + # Keys whose children are user-defined and should not be transformed + PRESERVE_CHILD_KEYS = %w[labels annotations].freeze + # Recursively transform keys in hashes and arrays # + # Keys under `labels` and `annotations` are user-defined and preserved as-is. + # # @param object [Hash, Array, Object] The object to transform + # @param preserve_children [Boolean] If true, skip key transformation (inside user-defined keys) # @param block [Proc] Block to transform each key # @return [Hash, Array, Object] The transformed object # @api private - def self.deep_transform_keys(object, &block) + def self.deep_transform_keys(object, preserve_children: false, &block) case object when Hash - object.each_with_object({}) do |(key, value), result| - result[yield(key).to_sym] = deep_transform_keys(value, &block) - end + transform_hash_keys(object, preserve_children, &block) when Array - object.map { |element| deep_transform_keys(element, &block) } + object.map { |e| deep_transform_keys(e, preserve_children: preserve_children, &block) } else object end end - private_class_method :deep_transform_keys + def self.transform_hash_keys(hash, preserve_children, &block) + hash.each_with_object({}) do |(key, value), result| + new_key = preserve_children ? key.to_sym : yield(key).to_sym + preserve = preserve_children || PRESERVE_CHILD_KEYS.include?(key.to_s.downcase) + result[new_key] = deep_transform_keys(value, preserve_children: preserve, &block) + end + end + + private_class_method :deep_transform_keys, :transform_hash_keys end end diff --git a/lib/pug_client/patch_generator.rb b/lib/pug_client/patch_generator.rb index f5e5a90..e37d1d6 100644 --- a/lib/pug_client/patch_generator.rb +++ b/lib/pug_client/patch_generator.rb @@ -31,7 +31,7 @@ def self.generate(changes, field_mappings: {}) { op: 'add', path: json_pointer(change[:path], field_mappings), - value: convert_value(change[:value]) + value: convert_value(change[:value], change[:path]) } when :remove { @@ -42,7 +42,7 @@ def self.generate(changes, field_mappings: {}) { op: 'replace', path: json_pointer(change[:path], field_mappings), - value: convert_value(change[:new_value]) + value: convert_value(change[:new_value], change[:path]) } end end @@ -76,8 +76,11 @@ def self.json_pointer(path_array, field_mappings = {}) # @param value [Object] Value to convert # @return [Object] Value with camelCase keys # @api private - def self.convert_value(value) + def self.convert_value(value, path = []) value = value.to_h if value.is_a?(TrackedHash) + # If path is inside user-defined keys (labels/annotations), preserve as-is + return value if path.any? { |seg| AttributeTranslator::PRESERVE_CHILD_KEYS.include?(seg.to_s.downcase) } + AttributeTranslator.to_api(value) end diff --git a/spec/integration/videos_spec.rb b/spec/integration/videos_spec.rb index 5f3f329..8145d65 100644 --- a/spec/integration/videos_spec.rb +++ b/spec/integration/videos_spec.rb @@ -134,8 +134,10 @@ expect(video.save).to be true + # The VCR cassette has the API returning 'customField' (as sent by old client). + # With preserve logic, label keys from the API are kept as-is. reloaded = client.video(video.id) - expect(reloaded.metadata.dig(:labels, :custom_field)).to eq('value') + expect(reloaded.metadata.dig(:labels, :customField)).to eq('value') end it 'does not save when no changes made' do diff --git a/spec/pug_client/attribute_translator_spec.rb b/spec/pug_client/attribute_translator_spec.rb index e454a0f..2116961 100644 --- a/spec/pug_client/attribute_translator_spec.rb +++ b/spec/pug_client/attribute_translator_spec.rb @@ -73,7 +73,7 @@ expect(output).to eq({ started_at: '2025-01-01', ended_at: '2025-01-02' }) end - it 'converts nested hashes' do + it 'converts nested hashes but preserves label keys' do input = { 'metadata' => { 'labels' => { 'gameId' => '123' }, @@ -84,7 +84,7 @@ expect(output).to eq({ metadata: { - labels: { game_id: '123' }, + labels: { gameId: '123' }, created_at: '2025-01-01' } }) @@ -107,7 +107,7 @@ }) end - it 'handles mixed nesting (arrays and hashes)' do + it 'handles mixed nesting and preserves label keys' do input = { 'videos' => [ { @@ -125,7 +125,7 @@ { id: '1', metadata: { - labels: { sport_type: 'basketball' } + labels: { sportType: 'basketball' } } } ] @@ -172,7 +172,7 @@ expect(output).to eq({ startedAt: '2025-01-01', endedAt: '2025-01-02' }) end - it 'converts nested hashes' do + it 'converts nested hashes but preserves label keys' do input = { metadata: { labels: { game_id: '123' }, @@ -183,7 +183,7 @@ expect(output).to eq({ metadata: { - labels: { gameId: '123' }, + labels: { game_id: '123' }, createdAt: '2025-01-01' } }) @@ -207,7 +207,7 @@ }) end - it 'handles mixed nesting (arrays and hashes)' do + it 'handles mixed nesting but preserves label keys' do input = { videos: [ { @@ -225,7 +225,7 @@ { id: '1', metadata: { - labels: { sportType: 'basketball' } + labels: { sport_type: 'basketball' } } } ] @@ -258,7 +258,7 @@ end describe 'round-trip conversion' do - it 'converts from API to Ruby and back' do + it 'converts from API to Ruby and back preserving label keys' do api_format = { 'startedAt' => '2025-01-01', 'metadata' => { @@ -268,9 +268,10 @@ } ruby_format = described_class.from_api(api_format) - back_to_api = described_class.to_api(ruby_format) + # Label keys are preserved as-is (user-defined) + expect(ruby_format[:metadata][:labels][:gameId]).to eq('123') - # Keys should match exactly (API uses standard camelCase) + back_to_api = described_class.to_api(ruby_format) expect(back_to_api[:startedAt]).to eq('2025-01-01') expect(back_to_api[:metadata][:labels][:gameId]).to eq('123') expect(back_to_api[:playbackUrls]).to eq(%w[url1 url2]) diff --git a/spec/pug_client/patch_generator_spec.rb b/spec/pug_client/patch_generator_spec.rb index 7240ab7..baf2691 100644 --- a/spec/pug_client/patch_generator_spec.rb +++ b/spec/pug_client/patch_generator_spec.rb @@ -186,7 +186,7 @@ expect(patches.first[:value]).to eq({ myKey: 'value' }) end - it 'handles nested TrackedHash' do + it 'handles nested TrackedHash and preserves label keys' do tracked = PugClient::TrackedHash.new({ labels: PugClient::TrackedHash.new({ my_key: 'value' }) }) @@ -196,8 +196,9 @@ patches = described_class.generate(changes) + # Label keys are user-defined and preserved as-is expect(patches.first[:value]).to eq({ - labels: { myKey: 'value' } + labels: { my_key: 'value' } }) end end @@ -257,8 +258,8 @@ patches = described_class.generate(changes) expect(patches.first[:value]).to eq({ - labels: { myLabel: 'value' }, - annotations: { myAnnotation: 'note' } + labels: { my_label: 'value' }, + annotations: { my_annotation: 'note' } }) end diff --git a/spec/pug_client/resources/live_stream_spec.rb b/spec/pug_client/resources/live_stream_spec.rb index c32e2bd..9a1acca 100644 --- a/spec/pug_client/resources/live_stream_spec.rb +++ b/spec/pug_client/resources/live_stream_spec.rb @@ -65,7 +65,7 @@ startedAt: '2024-01-01T12:00:00Z', metadata: { labels: { event: 'championship' }, - annotations: { gameId: '12345' } + annotations: { game_id: '12345' } }, location: location, simulcastTargets: %w[target-1 target-2] From f907dbf070a7b2d73930f50749b665fd25d04d35 Mon Sep 17 00:00:00 2001 From: Zach Norris Date: Tue, 24 Feb 2026 16:38:45 -0800 Subject: [PATCH 11/11] Sync resource definitions with API v1.2.0 and bump version to 1.0.1 Fix read-only attributes (Video: add source, remove stale playback_urls/ thumbnail_url; LiveStream: remove nonexistent thumbnails), add missing type fields to request bodies (Video clip: videoCommands, SimulcastTarget create: fix casing to simulcastTargets), and add livestream.edited webhook action. --- Gemfile.lock | 2 +- lib/pug_client/resources/live_stream.rb | 1 - lib/pug_client/resources/simulcast_target.rb | 2 +- lib/pug_client/resources/video.rb | 3 ++- lib/pug_client/resources/webhook.rb | 1 + lib/pug_client/version.rb | 2 +- spec/pug_client/resources/simulcast_target_spec.rb | 8 ++++---- spec/pug_client/resources/video_spec.rb | 2 ++ spec/pug_client/resources/webhook_spec.rb | 7 ++++--- 9 files changed, 16 insertions(+), 12 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 8c7483a..8ead50b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - pug-client (1.0.0) + pug-client (1.0.1) faraday (>= 2.14, < 3) GEM diff --git a/lib/pug_client/resources/live_stream.rb b/lib/pug_client/resources/live_stream.rb index f4cffec..09b16d9 100644 --- a/lib/pug_client/resources/live_stream.rb +++ b/lib/pug_client/resources/live_stream.rb @@ -15,7 +15,6 @@ class LiveStream < Resource stream_status stream_urls playback_urls - thumbnails ].freeze attr_reader :namespace_id diff --git a/lib/pug_client/resources/simulcast_target.rb b/lib/pug_client/resources/simulcast_target.rb index bce0c08..a31a211 100644 --- a/lib/pug_client/resources/simulcast_target.rb +++ b/lib/pug_client/resources/simulcast_target.rb @@ -84,7 +84,7 @@ def self.create(client, namespace_id, url, options = {}) body = { data: { - type: 'SimulcastTargets', + type: 'simulcastTargets', attributes: attributes } } diff --git a/lib/pug_client/resources/video.rb b/lib/pug_client/resources/video.rb index f259fd2..fb50108 100644 --- a/lib/pug_client/resources/video.rb +++ b/lib/pug_client/resources/video.rb @@ -26,7 +26,7 @@ class Video < Resource # Attributes that cannot be modified after creation READ_ONLY_ATTRIBUTES = %i[ id created_at modified_at duration - renditions playback_urls thumbnail_url playback + renditions source playback playback_start playback_stop ].freeze @@ -212,6 +212,7 @@ def clip(start_time:, duration:, **options) body = { data: { + type: 'videoCommands', attributes: api_attributes } } diff --git a/lib/pug_client/resources/webhook.rb b/lib/pug_client/resources/webhook.rb index ec5ba1e..0b09953 100644 --- a/lib/pug_client/resources/webhook.rb +++ b/lib/pug_client/resources/webhook.rb @@ -23,6 +23,7 @@ class Webhook < Resource ACTIONS = %w[ video.ready video.source.uploaded video.edited video.deleted livestream.published livestream.unpublished livestream.disabled livestream.enabled + livestream.edited simulcasttarget.created simulcasttarget.edited simulcasttarget.started simulcasttarget.stopped simulcasttarget.deleted ].freeze diff --git a/lib/pug_client/version.rb b/lib/pug_client/version.rb index 8962df1..7000ed5 100644 --- a/lib/pug_client/version.rb +++ b/lib/pug_client/version.rb @@ -2,5 +2,5 @@ module PugClient # Current version of the PugClient gem - VERSION = '1.0.0' + VERSION = '1.0.1' end diff --git a/spec/pug_client/resources/simulcast_target_spec.rb b/spec/pug_client/resources/simulcast_target_spec.rb index f807200..914ca9c 100644 --- a/spec/pug_client/resources/simulcast_target_spec.rb +++ b/spec/pug_client/resources/simulcast_target_spec.rb @@ -10,7 +10,7 @@ let(:api_response) do build_api_response( - type: 'SimulcastTargets', + type: 'simulcastTargets', id: target_id, attributes: { 'url' => rtmp_url, @@ -30,7 +30,7 @@ it 'creates a new simulcast target with URL' do expected_body = { data: { - type: 'SimulcastTargets', + type: 'simulcastTargets', attributes: { url: rtmp_url } @@ -54,7 +54,7 @@ expected_body = { data: { - type: 'SimulcastTargets', + type: 'simulcastTargets', attributes: { url: rtmp_url, metadata: metadata @@ -137,7 +137,7 @@ attributes: { data: { id: target_id, - type: 'SimulcastTargets', + type: 'simulcastTargets', attributes: { 'url' => long_url } } } diff --git a/spec/pug_client/resources/video_spec.rb b/spec/pug_client/resources/video_spec.rb index 96f9eae..1a1d279 100644 --- a/spec/pug_client/resources/video_spec.rb +++ b/spec/pug_client/resources/video_spec.rb @@ -345,6 +345,7 @@ expect(client).to receive(:post) .with("namespaces/#{namespace_id}/videos/#{video_id}/commands", { data: { + type: 'videoCommands', attributes: { command: 'clip', startTime: 5000, @@ -365,6 +366,7 @@ expect(client).to receive(:post) .with("namespaces/#{namespace_id}/videos/#{video_id}/commands", { data: { + type: 'videoCommands', attributes: { command: 'clip', startTime: 5000, diff --git a/spec/pug_client/resources/webhook_spec.rb b/spec/pug_client/resources/webhook_spec.rb index 936642d..c3813f6 100644 --- a/spec/pug_client/resources/webhook_spec.rb +++ b/spec/pug_client/resources/webhook_spec.rb @@ -328,9 +328,9 @@ end describe 'ACTIONS' do - it 'contains all 13 webhook action strings' do + it 'contains all 14 webhook action strings' do expect(described_class::ACTIONS).to be_a(Array) - expect(described_class::ACTIONS.length).to eq(13) + expect(described_class::ACTIONS.length).to eq(14) end it 'includes video actions' do @@ -342,7 +342,8 @@ it 'includes livestream actions' do expect(described_class::ACTIONS).to include( 'livestream.published', 'livestream.unpublished', - 'livestream.disabled', 'livestream.enabled' + 'livestream.disabled', 'livestream.enabled', + 'livestream.edited' ) end