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

## Version 0.10.0
### Date: 27th-July-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.9.2
### Date: 6th-July-2026
### Fix
Expand Down
4 changes: 2 additions & 2 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
contentstack (0.9.2)
contentstack (0.10.0)
activesupport (>= 3.2)
contentstack_utils (~> 1.2)

Expand Down Expand Up @@ -119,7 +119,7 @@ CHECKSUMS
bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd
concurrent-ruby (1.3.7) sha256=4412caec3a5ea2e5fdc52076724c071a81f2c0593d83b2ac8cbb8ca63b3151b0
connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a
contentstack (0.9.2)
contentstack (0.10.0)
contentstack_utils (1.2.3) sha256=cf2f5f996eb487559fd2d7d48a99262710f53dec62c84c6e325b9a598cd31ba7
crack (1.0.1) sha256=ff4a10390cd31d66440b7524eb1841874db86201d5b70032028553130b6d4c7e
diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962
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.9.2"
VERSION = "0.10.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