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/Gemfile.lock b/Gemfile.lock index 0796e9a..8ead50b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - pug-client (0.1.3) + pug-client (1.0.1) 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/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/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/patch_generator.rb b/lib/pug_client/patch_generator.rb index 2a2756c..e37d1d6 100644 --- a/lib/pug_client/patch_generator.rb +++ b/lib/pug_client/patch_generator.rb @@ -24,25 +24,25 @@ 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]), - value: convert_value(change[:value]) + path: json_pointer(change[:path], field_mappings), + value: convert_value(change[:value], change[:path]) } 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]), - value: convert_value(change[:new_value]) + path: json_pointer(change[:path], field_mappings), + value: convert_value(change[:new_value], change[:path]) } end 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 @@ -73,8 +76,11 @@ def self.json_pointer(path_array) # @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/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/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/campaign.rb b/lib/pug_client/resources/campaign.rb index c9ff518..595f80a 100644 --- a/lib/pug_client/resources/campaign.rb +++ b/lib/pug_client/resources/campaign.rb @@ -11,10 +11,14 @@ class Campaign < Resource READ_ONLY_ATTRIBUTES = %i[ id created_at - updated_at + modified_at 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/live_stream.rb b/lib/pug_client/resources/live_stream.rb index 1c5cd1f..09b16d9 100644 --- a/lib/pug_client/resources/live_stream.rb +++ b/lib/pug_client/resources/live_stream.rb @@ -11,12 +11,10 @@ class LiveStream < Resource READ_ONLY_ATTRIBUTES = %i[ id created_at - updated_at - started_at + modified_at stream_status stream_urls playback_urls - thumbnails ].freeze attr_reader :namespace_id @@ -57,7 +55,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, @@ -85,7 +86,7 @@ def self.create(client, namespace_id, options = {}) body = { data: { - type: 'LiveStreams', + type: 'liveStreams', attributes: attributes } } diff --git a/lib/pug_client/resources/namespace.rb b/lib/pug_client/resources/namespace.rb index c4b7c11..263c7ce 100644 --- a/lib/pug_client/resources/namespace.rb +++ b/lib/pug_client/resources/namespace.rb @@ -16,15 +16,11 @@ 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 # 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 # @@ -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) @@ -228,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/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..a31a211 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 @@ -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, @@ -81,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 3be64d0..fb50108 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 updated_at duration started_at - renditions playback_urls thumbnail_url playback source + id created_at modified_at duration + renditions source playback + playback_start playback_stop ].freeze # Supported video content types for upload @@ -72,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, @@ -208,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 e022d13..0b09953 100644 --- a/lib/pug_client/resources/webhook.rb +++ b/lib/pug_client/resources/webhook.rb @@ -11,7 +11,21 @@ class Webhook < Resource READ_ONLY_ATTRIBUTES = %i[ id created_at - updated_at + 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 + livestream.edited + simulcasttarget.created simulcasttarget.edited simulcasttarget.started + simulcasttarget.stopped simulcasttarget.deleted ].freeze attr_reader :namespace_id diff --git a/lib/pug_client/version.rb b/lib/pug_client/version.rb index 8a98661..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 = '0.1.3' + VERSION = '1.0.1' 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/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/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/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/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 diff --git a/spec/pug_client/resources/campaign_spec.rb b/spec/pug_client/resources/campaign_spec.rb index 50ddf73..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, @@ -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,20 +392,20 @@ 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 - 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') diff --git a/spec/pug_client/resources/live_stream_spec.rb b/spec/pug_client/resources/live_stream_spec.rb index a0a2b2f..9a1acca 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,12 +60,12 @@ "namespaces/#{namespace_id}/livestreams", { data: { - type: 'LiveStreams', + type: 'liveStreams', attributes: { 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] @@ -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' @@ -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 fd8f71e..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' } } } ) @@ -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 @@ -358,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/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 89045ff..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, @@ -554,7 +556,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' }, @@ -581,6 +583,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) diff --git a/spec/pug_client/resources/webhook_spec.rb b/spec/pug_client/resources/webhook_spec.rb index 14dcc96..c3813f6 100644 --- a/spec/pug_client/resources/webhook_spec.rb +++ b/spec/pug_client/resources/webhook_spec.rb @@ -327,6 +327,39 @@ end end + describe 'ACTIONS' do + it 'contains all 14 webhook action strings' do + expect(described_class::ACTIONS).to be_a(Array) + expect(described_class::ACTIONS.length).to eq(14) + 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', + 'livestream.edited' + ) + 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) 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