Skip to content
Draft
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
## CHANGELOG

## Version 0.9.0
### Date: 19th-May-2026
### Features
- Added `variants(variant_uids, branch_name)` on `Contentstack::Entry` and `Contentstack::Query` to fetch entry variants with optional per-request branch scoping. Requests send the `x-cs-variant-uid` header (comma-separated UIDs) and respect stack-level or per-call branch.

## Version 0.8.4
### Date: 15th-April-2026
### Security and Compatibility
Expand Down
8 changes: 4 additions & 4 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
contentstack (0.8.4)
contentstack (0.8.5)
activesupport (>= 3.2)
contentstack_utils (~> 1.2)

Expand Down Expand Up @@ -39,12 +39,12 @@ GEM
hashdiff (1.2.1)
i18n (1.14.8)
concurrent-ruby (~> 1.0)
json (2.19.4)
json (2.19.5)
logger (1.7.0)
minitest (6.0.5)
minitest (6.0.6)
drb (~> 2.0)
prism (~> 1.5)
nokogiri (1.19.2-arm64-darwin)
nokogiri (1.19.3-arm64-darwin)
racc (~> 1.4)
prism (1.9.0)
public_suffix (7.0.5)
Expand Down
56 changes: 48 additions & 8 deletions lib/contentstack/api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,51 @@ def self.get_sync_items(query)
fetch_retry(path, query)
end

def self.validate_variant_uids!(variant_uids)
if variant_uids.nil? || (variant_uids.respond_to?(:empty?) && variant_uids.empty?)
raise Contentstack::Error.new("Variant UID(s) are required. Provide a variant UID or an array of variant UIDs.")
end
unless variant_uids.is_a?(String) || variant_uids.is_a?(Array)
raise Contentstack::Error.new("Variant UID(s) must be a String or Array of Strings.")
end
if variant_uids.is_a?(Array) && variant_uids.any? { |uid| !uid.is_a?(String) || uid.empty? }
raise Contentstack::Error.new("Variant UID(s) must be a String or Array of Strings.")
end
end

private
def self.prepare_query(q)
q = (q || {}).dup
variant_uids = q.delete(:variant_uids)
branch_override = q.delete(:branch)
[q, variant_uids, branch_override]
end

def self.format_variant_uids(variant_uids)
return nil if variant_uids.nil?

case variant_uids
when String
variant_uids.strip
when Array
variant_uids.map(&:to_s).reject(&:empty?).join(', ')
end
end

def self.resolve_branch(branch_override)
branch = branch_override
branch = @branch if branch.nil? || branch.to_s.empty?
branch
end

def self.apply_variant_headers(params, variant_uids, branch_override)
formatted = format_variant_uids(variant_uids)
params["x-cs-variant-uid"] = formatted if formatted && !formatted.empty?

branch = resolve_branch(branch_override)
params["branch"] = branch if !branch.nil? && !branch.empty?
params
end
def self.fetch_retry(path, query=nil, count=0)
response = send_request(path, query)
if @errorRetry.include?(response["status_code"].to_i)
Expand All @@ -90,7 +134,7 @@ def self.fetch_retry(path, query=nil, count=0)
end

def self.send_request(path, q=nil)
q ||= {}
q, variant_uids, branch_override = prepare_query(q)

q.merge!(@headers)

Expand All @@ -103,9 +147,7 @@ def self.send_request(path, q=nil)
"x-user-agent" => "ruby-sdk/#{Contentstack::VERSION}",
"read_timeout" => @timeout
}
if !@branch.nil? && !@branch.empty?
params["branch"] = @branch
end
apply_variant_headers(params, variant_uids, branch_override)

if @proxy_details.present? && @proxy_details[:url].present? && @proxy_details[:port].present? && @proxy_details[:username].empty? && @proxy_details[:password].empty?
params["proxy"] = URI.parse("http://#{@proxy_details[:url]}:#{@proxy_details[:port]}/").to_s
Expand All @@ -130,7 +172,7 @@ def self.send_request(path, q=nil)
end

def self.send_preview_request(path, q=nil)
q ||= {}
q, variant_uids, branch_override = prepare_query(q)

q.merge!({live_preview: (!@live_preview.key?(:live_preview) ? 'init' : @live_preview[:live_preview]),})

Expand All @@ -143,9 +185,7 @@ def self.send_preview_request(path, q=nil)
"x-user-agent" => "ruby-sdk/#{Contentstack::VERSION}",
"read_timeout" => @timeout
}
if !@branch.nil? && !@branch.empty?
params["branch"] = @branch
end
apply_variant_headers(params, variant_uids, branch_override)

if @proxy_details.present? && @proxy_details[:url].present? && @proxy_details[:port].present? && @proxy_details[:username].empty? && @proxy_details[:password].empty?
params["proxy"] = URI.parse("http://#{@proxy_details[:url]}:#{@proxy_details[:port]}/").to_s
Expand Down
20 changes: 20 additions & 0 deletions lib/contentstack/entry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,26 @@ def include_metadata(flag=true)
self
end

# Scope the entry request to one or more entry variants, optionally on a branch.
#
# @param [String, Array<String>] variant_uids A variant UID, or an array of variant UIDs
# @param [String] branch_name Branch name to scope the request (overrides stack-level branch)
#
# Example
#
# @entry = @stack.content_type('home_page').entry(entry_uid)
# @entry.variants('xyz', 'branch_name').fetch
#
# @entry = @stack.content_type('home_page').entry(entry_uid)
# @entry.variants(['variant1', 'variant2'], 'branch_name').fetch
#
# @return [Contentstack::Entry]
def variants(variant_uids, branch_name = nil)
API.validate_variant_uids!(variant_uids)
@query[:variant_uids] = variant_uids
@query[:branch] = branch_name if branch_name.is_a?(String) && !branch_name.empty?
self
end

# Include Embedded Objects (Entries and Assets) along with entry/entries details.
#
Expand Down
21 changes: 21 additions & 0 deletions lib/contentstack/query.rb
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,27 @@ def include_branch(flag=true)
self
end

# Scope the entries request to one or more entry variants, optionally on a branch.
#
# @param [String, Array<String>] variant_uids A variant UID, or an array of variant UIDs
# @param [String] branch_name Branch name to scope the request (overrides stack-level branch)
#
# Example
#
# @query = @stack.content_type('home_page').query
# @query.variants('xyz', 'branch_name').fetch
#
# @query = @stack.content_type('home_page').query
# @query.variants(['variant1', 'variant2'], 'branch_name').fetch
#
# @return [Contentstack::Query]
def variants(variant_uids, branch_name = nil)
API.validate_variant_uids!(variant_uids)
@query[:variant_uids] = variant_uids
@query[:branch] = branch_name if branch_name.is_a?(String) && !branch_name.empty?
self
end

# Include Embedded Objects (Entries and Assets) along with entry/entries details.
#
# Example
Expand Down
2 changes: 1 addition & 1 deletion lib/contentstack/version.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
module Contentstack
VERSION = "0.8.4"
VERSION = "0.9.0"
end
97 changes: 97 additions & 0 deletions spec/variants_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
require 'spec_helper'

describe 'entry variants' do
let(:client) { create_client }
let(:branch_client) { create_client(ENV['DELIVERY_TOKEN'], ENV['API_KEY'], ENV['ENVIRONMENT'], { branch: 'stack_branch' }) }
let(:entry_uid) { 'uid' }
let(:category_entry) { client.content_type('category').entry(entry_uid) }
let(:category_query) { client.content_type('category').query }

describe Contentstack::Entry do
it 'stores variant UID and branch on the query' do
entry = category_entry.variants('variant1', 'branch_name')
expect(entry.query[:variant_uids]).to eq 'variant1'
expect(entry.query[:branch]).to eq 'branch_name'
end

it 'stores multiple variant UIDs' do
entry = category_entry.variants(['variant1', 'variant2'], 'branch_name')
expect(entry.query[:variant_uids]).to eq ['variant1', 'variant2']
end

it 'raises when variant UIDs are missing' do
expect { category_entry.variants(nil) }.to raise_error(Contentstack::Error, /Variant UID/)
expect { category_entry.variants([]) }.to raise_error(Contentstack::Error, /Variant UID/)
end

it 'raises when variant UIDs are invalid' do
expect { category_entry.variants(123) }.to raise_error(Contentstack::Error, /String or Array/)
expect { category_entry.variants(['']) }.to raise_error(Contentstack::Error, /String or Array/)
end
end

describe Contentstack::Query do
it 'stores variant UID and branch on the query' do
query = category_query.variants('variant1', 'branch_name')
expect(query.query[:variant_uids]).to eq 'variant1'
expect(query.query[:branch]).to eq 'branch_name'
end
end

describe 'HTTP headers' do
it 'sends x-cs-variant-uid and branch for a single entry fetch' do
stub = stub_request(:get, /cdn\.contentstack\.io\/v3\/content_types\/category\/entries\/uid/).
with { |req|
req.headers['X-Cs-Variant-Uid'] == 'variant1' &&
req.headers['Branch'] == 'branch_name'
}.
to_return(status: 200, body: File.read(File.dirname(__FILE__) + '/fixtures/category_entry.json'), headers: {})

category_entry.variants('variant1', 'branch_name').fetch
expect(stub).to have_been_requested
end

it 'sends comma-separated variant UIDs for multiple variants' do
stub = stub_request(:get, /cdn\.contentstack\.io\/v3\/content_types\/category\/entries\/uid/).
with { |req| req.headers['X-Cs-Variant-Uid'] == 'variant1, variant2' }.
to_return(status: 200, body: File.read(File.dirname(__FILE__) + '/fixtures/category_entry.json'), headers: {})

category_entry.variants(['variant1', 'variant2'], 'branch_name').fetch
expect(stub).to have_been_requested
end

it 'sends x-cs-variant-uid and branch for an entries query' do
stub = stub_request(:get, /cdn\.contentstack\.io\/v3\/content_types\/category\/entries/).
with { |req|
!req.uri.path.include?('/entries/uid') &&
req.headers['X-Cs-Variant-Uid'] == 'variant1' &&
req.headers['Branch'] == 'branch_name'
}.
to_return(status: 200, body: File.read(File.dirname(__FILE__) + '/fixtures/category_entry_collection_without_count.json'), headers: {})

category_query.variants('variant1', 'branch_name').fetch
expect(stub).to have_been_requested
end

it 'uses stack-level branch when variants branch is omitted' do
stub = stub_request(:get, /cdn\.contentstack\.io\/v3\/content_types\/category\/entries\/uid/).
with { |req|
req.headers['X-Cs-Variant-Uid'] == 'variant1' &&
req.headers['Branch'] == 'stack_branch'
}.
to_return(status: 200, body: File.read(File.dirname(__FILE__) + '/fixtures/category_entry.json'), headers: {})

branch_client.content_type('category').entry(entry_uid).variants('variant1').fetch
expect(stub).to have_been_requested
end

it 'does not add variant headers when variants is not chained' do
stub = stub_request(:get, /cdn\.contentstack\.io\/v3\/content_types\/category\/entries\/uid/).
with { |req| req.headers['X-Cs-Variant-Uid'].nil? }.
to_return(status: 200, body: File.read(File.dirname(__FILE__) + '/fixtures/category_entry.json'), headers: {})

category_entry.fetch
expect(stub).to have_been_requested
end
end
end
Loading