Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
pug-client (0.1.3)
pug-client (1.0.1)
faraday (>= 2.14, < 3)

GEM
Expand Down
24 changes: 18 additions & 6 deletions lib/pug_client/attribute_translator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion lib/pug_client/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion lib/pug_client/connection.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
24 changes: 15 additions & 9 deletions lib/pug_client/patch_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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

Expand Down
19 changes: 18 additions & 1 deletion lib/pug_client/resource.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion lib/pug_client/resource_enumerator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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] ||= {}
Expand Down
65 changes: 39 additions & 26 deletions lib/pug_client/resources/campaign.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -68,36 +72,45 @@ 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)
rescue StandardError => e
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
Expand Down Expand Up @@ -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
Expand Down
9 changes: 5 additions & 4 deletions lib/pug_client/resources/live_stream.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -85,7 +86,7 @@ def self.create(client, namespace_id, options = {})

body = {
data: {
type: 'LiveStreams',
type: 'liveStreams',
attributes: attributes
}
}
Expand Down
Loading