From 907782e276ae93d4c9322b14b41c270b2821910b Mon Sep 17 00:00:00 2001 From: sunil-lakshman <104969541+sunil-lakshman@users.noreply.github.com> Date: Wed, 10 Jun 2026 13:54:38 +0530 Subject: [PATCH] Added URL endpoint support --- .gitignore | 3 +- CHANGELOG.md | 12 +++ Gemfile.lock | 5 +- lib/contentstack.rb | 27 +++-- lib/contentstack/client.rb | 46 ++------- lib/contentstack/endpoint.rb | 114 +++++++++++++++++++++ lib/contentstack/error.rb | 8 ++ lib/contentstack/region.rb | 23 +++-- lib/contentstack/version.rb | 2 +- rakefile.rb | 11 ++- spec/endpoint_spec.rb | 185 +++++++++++++++++++++++++++++++++++ 11 files changed, 378 insertions(+), 58 deletions(-) create mode 100644 lib/contentstack/endpoint.rb create mode 100644 spec/endpoint_spec.rb diff --git a/.gitignore b/.gitignore index 43e8c09..d9b944e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,5 @@ spec/.env.test .bundle/ **/rspec_results.html vendor/ -.dccache \ No newline at end of file +.dccache +lib/data/regions.json \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index f1b5413..0e92366 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ ## CHANGELOG +## Version 0.9.0 +### Date: 15th-June-2026 + ### Enhancement + - Introduced centralized endpoint resolution via `Contentstack::Endpoint.get_contentstack_endpoint(region, service)`, eliminating all hardcoded Contentstack hostnames from the SDK. + - Added `Contentstack.get_contentstack_endpoint` as a backward-compatible module-level proxy, aligned with the `ContentstackUtils` endpoint resolution API. + - Added `Contentstack::Service` class with `CDA`, `CMA`, and `PREVIEW` constants. + - Added `Contentstack::Region::GCP_EU` region constant. + - Endpoint URLs are driven by a local `lib/data/regions.json` file with automatic runtime fallback to the Contentstack registry when the file is absent. + - Added `bundle exec rake refresh_regions` task to manually update region metadata from the registry. + +------------------------------------------------ + ## Version 0.8.5 ### Date: 5th-June-2026 ### Deprecated diff --git a/Gemfile.lock b/Gemfile.lock index 8fa4794..eae5564 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -39,7 +39,7 @@ GEM hashdiff (1.2.1) i18n (1.14.8) concurrent-ruby (~> 1.0) - json (2.19.7) + json (2.19.8) logger (1.7.0) minitest (6.0.6) drb (~> 2.0) @@ -116,6 +116,7 @@ CHECKSUMS addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd + bundler (4.0.11) sha256=5bcec0fb78302e48d02ee46f10ee6e6942be647ba5b44a6d1ddfda9a240ce785 concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a contentstack (0.8.5) @@ -126,7 +127,7 @@ CHECKSUMS drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 hashdiff (1.2.1) sha256=9c079dbc513dfc8833ab59c0c2d8f230fa28499cc5efb4b8dd276cf931457cd1 i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5 - json (2.19.7) sha256=fe432c8639f6efff69f9d73b518a3705d9581ab93156f981ea72806e1e5bcc3e + json (2.19.8) sha256=6354310fd76ef69b87d5bd1f38b40d730613baf90b6803d2d0a48f618d32dfaa logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1 nokogiri (1.19.3-aarch64-linux-gnu) sha256=46b89e5d7b9e844c2ee360794240c6ea2a4e6fa0c5892a4ed487db621224b639 diff --git a/lib/contentstack.rb b/lib/contentstack.rb index 839ba0c..d16cf1e 100644 --- a/lib/contentstack.rb +++ b/lib/contentstack.rb @@ -3,6 +3,7 @@ require "contentstack/version" require "contentstack/client" require "contentstack/region" +require "contentstack/endpoint" require "contentstack_utils" # == Contentstack - Ruby SDK @@ -23,10 +24,24 @@ # ==== Query entries # @stack.content_type('blog').query.regex('title', '.*hello.*').fetch module Contentstack - def self.render_content(content, options) - ContentstackUtils.render_content(content, options) - end - def self.json_to_html(content, options) - ContentstackUtils.json_to_html(content, options) - end + def self.render_content(content, options) + ContentstackUtils.render_content(content, options) + end + + def self.json_to_html(content, options) + ContentstackUtils.json_to_html(content, options) + end + + # Backward-compatible proxy for endpoint resolution. + # Delegates to ContentstackUtils.get_contentstack_endpoint when available, + # otherwise resolves via Contentstack::Endpoint. + # + # Contentstack.get_contentstack_endpoint('eu') + # # => "https://eu-cdn.contentstack.com" + # + # Contentstack.get_contentstack_endpoint('us', 'cma') + # # => "https://api.contentstack.io" + def self.get_contentstack_endpoint(region, service = Contentstack::Service::CDA) + Contentstack::Endpoint.get_contentstack_endpoint(region, service) + end end \ No newline at end of file diff --git a/lib/contentstack/client.rb b/lib/contentstack/client.rb index 7d9a0cd..afb6416 100644 --- a/lib/contentstack/client.rb +++ b/lib/contentstack/client.rb @@ -2,6 +2,7 @@ require 'contentstack/content_type' require 'contentstack/asset_collection' require 'contentstack/sync_result' +require 'contentstack/endpoint' require 'util' require 'contentstack/error' module Contentstack @@ -80,47 +81,14 @@ def sync(params) end private - def get_default_region_hosts(region='us') - host = "#{Contentstack::Host::PROTOCOL}#{Contentstack::Host::DEFAULT_HOST}" #set default host if region is nil - case region - when "us" - host = "#{Contentstack::Host::PROTOCOL}#{Contentstack::Host::DEFAULT_HOST}" - when "eu" - host = "#{Contentstack::Host::PROTOCOL}eu-cdn.#{Contentstack::Host::HOST}" - when "azure-na" - host = "#{Contentstack::Host::PROTOCOL}azure-na-cdn.#{Contentstack::Host::HOST}" - when "azure-eu" - host = "#{Contentstack::Host::PROTOCOL}azure-eu-cdn.#{Contentstack::Host::HOST}" - when "gcp-na" - host = "#{Contentstack::Host::PROTOCOL}gcp-na-cdn.#{Contentstack::Host::HOST}" - end - host - end def get_host_by_region(region, options) - if options[:host].nil? && region.present? - host = get_default_region_hosts(region) - elsif options[:host].present? && region.present? - custom_host = options[:host] - case region - when "us" - host = "#{Contentstack::Host::PROTOCOL}cdn.#{custom_host}" - when "eu" - host = "#{Contentstack::Host::PROTOCOL}eu-cdn.#{custom_host}" - when "azure-na" - host = "#{Contentstack::Host::PROTOCOL}azure-na-cdn.#{custom_host}" - when "azure-eu" - host = "#{Contentstack::Host::PROTOCOL}azure-eu-cdn.#{custom_host}" - when "gcp-na" - host = "#{Contentstack::Host::PROTOCOL}gcp-na-cdn.#{custom_host}" - end - elsif options[:host].present? && region.empty? - custom_host = options[:host] - host = "#{Contentstack::Host::PROTOCOL}cdn.#{custom_host}" - else - host = "#{Contentstack::Host::PROTOCOL}#{Contentstack::Host::DEFAULT_HOST}" #set default host if region and host is empty - end - host + custom_host = options[:host] + Contentstack::Endpoint.get_contentstack_endpoint( + region.present? ? region : Contentstack::Region::US, + Contentstack::Service::CDA, + custom_host.present? ? custom_host : nil + ) end end diff --git a/lib/contentstack/endpoint.rb b/lib/contentstack/endpoint.rb new file mode 100644 index 0000000..779720f --- /dev/null +++ b/lib/contentstack/endpoint.rb @@ -0,0 +1,114 @@ +require 'json' +require 'net/http' +require 'uri' +require 'fileutils' +require 'contentstack/error' + +module Contentstack + # Centralised endpoint resolver. Reads region→service→URL mappings from the + # bundled regions.json and falls back to the live registry when the file is + # absent. Delegating to ContentstackUtils.get_contentstack_endpoint is + # preferred when that gem exposes the method (PR #41 of contentstack-utils-ruby). + class Endpoint + REGISTRY_URL = 'https://raw.githubusercontent.com/contentstack/contentstack-utils-ruby/main/lib/data/regions.json' + DATA_FILE_PATH = File.join(File.dirname(File.dirname(__FILE__)), 'data', 'regions.json') + + DEFAULT_SERVICE = 'cda' + + # Resolve a Contentstack service URL for the given region and service. + # + # Contentstack::Endpoint.get_contentstack_endpoint('eu') + # # => "https://eu-cdn.contentstack.com" + # + # Contentstack::Endpoint.get_contentstack_endpoint('us', 'cma') + # # => "https://api.contentstack.io" + # + # When +custom_host+ is supplied the region CDN prefix is derived from + # regions.json and prepended to the custom domain, preserving the + # existing SDK behaviour for host-override configurations. + def self.get_contentstack_endpoint(region, service = DEFAULT_SERVICE, custom_host = nil) + region_key = region.to_s.downcase + service_key = service.to_s.downcase + + if custom_host.nil? || custom_host.to_s.empty? + # Prefer the utils SDK when it ships endpoint resolution (PR #41) + if defined?(ContentstackUtils) && ContentstackUtils.respond_to?(:get_contentstack_endpoint) + return ContentstackUtils.get_contentstack_endpoint(region_key, service_key) + end + resolve_standard(region_key, service_key) + else + resolve_custom_host(region_key, service_key, custom_host) + end + end + + # Download and persist the latest region metadata from the registry. + # Equivalent to `composer refresh-regions` in the PHP SDK. + # + # Rake: bundle exec rake refresh_regions + def self.refresh_regions + data = fetch_from_registry + FileUtils.mkdir_p(File.dirname(DATA_FILE_PATH)) + File.write(DATA_FILE_PATH, JSON.pretty_generate(data)) + data + end + + private + + def self.resolve_standard(region_key, service_key) + regions = load_regions + unless regions.key?(region_key) + raise Contentstack::Error.new( + Contentstack::ErrorMessages.region_invalid(region_key, regions.keys) + ) + end + unless regions[region_key].key?(service_key) + raise Contentstack::Error.new( + Contentstack::ErrorMessages.service_invalid(service_key, regions[region_key].keys) + ) + end + regions[region_key][service_key] + end + + # Derive the CDN subdomain prefix from regions.json and combine with the + # caller-supplied custom domain, e.g. "eu-cdn" + "example.com" → + # "https://eu-cdn.example.com". + def self.resolve_custom_host(region_key, service_key, custom_host) + regions = load_regions + if regions.key?(region_key) && regions[region_key].key?(service_key) + standard_url = regions[region_key][service_key] + prefix = URI.parse(standard_url).host.split('.').first + "https://#{prefix}.#{custom_host}" + else + "https://cdn.#{custom_host}" + end + end + + def self.load_regions + if File.exist?(DATA_FILE_PATH) + JSON.parse(File.read(DATA_FILE_PATH)) + else + warn '[Contentstack] regions.json not found locally — fetching from registry...' + data = fetch_from_registry + begin + FileUtils.mkdir_p(File.dirname(DATA_FILE_PATH)) + File.write(DATA_FILE_PATH, JSON.pretty_generate(data)) + rescue => e + warn "[Contentstack] Could not cache regions.json: #{e.message}" + end + data + end + end + + def self.fetch_from_registry + uri = URI.parse(REGISTRY_URL) + response = Net::HTTP.get_response(uri) + unless response.is_a?(Net::HTTPSuccess) + raise Contentstack::Error.new( + "Failed to fetch region metadata from registry (HTTP #{response.code}). " \ + 'Ensure network access and try again.' + ) + end + JSON.parse(response.body) + end + end +end diff --git a/lib/contentstack/error.rb b/lib/contentstack/error.rb index 288542a..a2e2795 100644 --- a/lib/contentstack/error.rb +++ b/lib/contentstack/error.rb @@ -17,6 +17,14 @@ def self.request_failed(response) def self.request_error(error) "The request encountered an issue due to #{error}. Review the details and try again." end + + def self.region_invalid(region, supported) + "Unknown region '#{region}'. Supported regions: #{supported.join(', ')}." + end + + def self.service_invalid(service, supported) + "Unknown service '#{service}'. Supported services: #{supported.join(', ')}." + end end class Error < StandardError diff --git a/lib/contentstack/region.rb b/lib/contentstack/region.rb index 88673eb..c6eaba7 100644 --- a/lib/contentstack/region.rb +++ b/lib/contentstack/region.rb @@ -1,15 +1,22 @@ module Contentstack class Region - EU='eu' - US='us' - AZURE_NA='azure-na' - AZURE_EU='azure-eu' - GCP_NA='gcp-na' + EU = 'eu' + US = 'us' + AZURE_NA = 'azure-na' + AZURE_EU = 'azure-eu' + GCP_NA = 'gcp-na' + GCP_EU = 'gcp-eu' + end + + class Service + CDA = 'cda' + CMA = 'cma' + PREVIEW = 'preview' end class Host - PROTOCOL='https://' - DEFAULT_HOST='cdn.contentstack.io' - HOST='contentstack.com' + PROTOCOL = 'https://' + DEFAULT_HOST = 'cdn.contentstack.io' + HOST = 'contentstack.com' end end diff --git a/lib/contentstack/version.rb b/lib/contentstack/version.rb index 684bf20..6cb99dd 100644 --- a/lib/contentstack/version.rb +++ b/lib/contentstack/version.rb @@ -1,3 +1,3 @@ module Contentstack - VERSION = "0.8.5" + VERSION = "0.9.0" end diff --git a/rakefile.rb b/rakefile.rb index 2308438..e3cc8f5 100644 --- a/rakefile.rb +++ b/rakefile.rb @@ -1,4 +1,13 @@ require 'yard' YARD::Rake::YardocTask.new do |t| - t.files = ["README.rdoc", 'lib/contentstack/*.rb', 'lib/contentstack.rb'] # optional + t.files = ["README.rdoc", 'lib/contentstack/*.rb', 'lib/contentstack.rb'] +end + +desc 'Download the latest region metadata from the Contentstack registry and update lib/data/regions.json' +task :refresh_regions do + require_relative 'lib/contentstack/endpoint' + require_relative 'lib/contentstack/error' + puts 'Fetching latest region metadata from registry...' + Contentstack::Endpoint.refresh_regions + puts "regions.json updated at: #{Contentstack::Endpoint::DATA_FILE_PATH}" end \ No newline at end of file diff --git a/spec/endpoint_spec.rb b/spec/endpoint_spec.rb new file mode 100644 index 0000000..4e02cc5 --- /dev/null +++ b/spec/endpoint_spec.rb @@ -0,0 +1,185 @@ +require 'spec_helper' +require_relative '../lib/contentstack' + +describe Contentstack::Endpoint do + let(:regions_data) do + JSON.parse(File.read(Contentstack::Endpoint::DATA_FILE_PATH)) + end + + # --------------------------------------------------------------------------- + # CDA endpoints (default service) + # --------------------------------------------------------------------------- + describe '.get_contentstack_endpoint - CDA (default)' do + { + 'us' => 'https://cdn.contentstack.io', + 'eu' => 'https://eu-cdn.contentstack.com', + 'azure-na' => 'https://azure-na-cdn.contentstack.com', + 'azure-eu' => 'https://azure-eu-cdn.contentstack.com', + 'gcp-na' => 'https://gcp-na-cdn.contentstack.com', + 'gcp-eu' => 'https://gcp-eu-cdn.contentstack.com' + }.each do |region, expected_url| + it "resolves #{region} CDA to #{expected_url}" do + expect(described_class.get_contentstack_endpoint(region)).to eq expected_url + end + end + end + + # --------------------------------------------------------------------------- + # CMA endpoints + # --------------------------------------------------------------------------- + describe '.get_contentstack_endpoint - CMA' do + { + 'us' => 'https://api.contentstack.io', + 'eu' => 'https://eu-api.contentstack.com', + 'azure-na' => 'https://azure-na-api.contentstack.com', + 'azure-eu' => 'https://azure-eu-api.contentstack.com', + 'gcp-na' => 'https://gcp-na-api.contentstack.com', + 'gcp-eu' => 'https://gcp-eu-api.contentstack.com' + }.each do |region, expected_url| + it "resolves #{region} CMA to #{expected_url}" do + expect(described_class.get_contentstack_endpoint(region, 'cma')).to eq expected_url + end + end + end + + # --------------------------------------------------------------------------- + # Preview endpoints + # --------------------------------------------------------------------------- + describe '.get_contentstack_endpoint - Preview' do + { + 'us' => 'https://preview.contentstack.io', + 'eu' => 'https://eu-preview.contentstack.com', + 'azure-na' => 'https://azure-na-preview.contentstack.com', + 'azure-eu' => 'https://azure-eu-preview.contentstack.com', + 'gcp-na' => 'https://gcp-na-preview.contentstack.com', + 'gcp-eu' => 'https://gcp-eu-preview.contentstack.com' + }.each do |region, expected_url| + it "resolves #{region} preview to #{expected_url}" do + expect(described_class.get_contentstack_endpoint(region, 'preview')).to eq expected_url + end + end + end + + # --------------------------------------------------------------------------- + # Service constants on Contentstack::Service + # --------------------------------------------------------------------------- + describe 'Contentstack::Service constants' do + it 'defines CDA' do + expect(Contentstack::Service::CDA).to eq 'cda' + end + + it 'defines CMA' do + expect(Contentstack::Service::CMA).to eq 'cma' + end + + it 'defines PREVIEW' do + expect(Contentstack::Service::PREVIEW).to eq 'preview' + end + end + + # --------------------------------------------------------------------------- + # Region constants on Contentstack::Region + # --------------------------------------------------------------------------- + describe 'Contentstack::Region constants' do + it 'includes GCP_EU' do + expect(Contentstack::Region::GCP_EU).to eq 'gcp-eu' + end + end + + # --------------------------------------------------------------------------- + # Custom host resolution + # --------------------------------------------------------------------------- + describe '.get_contentstack_endpoint - custom host' do + it 'prepends the correct CDN prefix for eu + custom host' do + expect(described_class.get_contentstack_endpoint('eu', 'cda', 'example.com')) + .to eq 'https://eu-cdn.example.com' + end + + it 'prepends the correct CDN prefix for azure-na + custom host' do + expect(described_class.get_contentstack_endpoint('azure-na', 'cda', 'example.com')) + .to eq 'https://azure-na-cdn.example.com' + end + + it 'prepends the correct CDN prefix for gcp-eu + custom host' do + expect(described_class.get_contentstack_endpoint('gcp-eu', 'cda', 'example.com')) + .to eq 'https://gcp-eu-cdn.example.com' + end + + it 'falls back to cdn. prefix for an unknown region + custom host' do + expect(described_class.get_contentstack_endpoint('unknown-region', 'cda', 'example.com')) + .to eq 'https://cdn.example.com' + end + end + + # --------------------------------------------------------------------------- + # Error handling + # --------------------------------------------------------------------------- + describe '.get_contentstack_endpoint - error handling' do + it 'raises Contentstack::Error for an unknown region (no custom host)' do + expect { described_class.get_contentstack_endpoint('mars') } + .to raise_error(Contentstack::Error, /Unknown region 'mars'/) + end + + it 'raises Contentstack::Error for an unknown service' do + expect { described_class.get_contentstack_endpoint('us', 'graphql') } + .to raise_error(Contentstack::Error, /Unknown service 'graphql'/) + end + end + + # --------------------------------------------------------------------------- + # Runtime fallback when regions.json is absent + # --------------------------------------------------------------------------- + describe '.get_contentstack_endpoint - runtime fallback' do + it 'fetches from registry when regions.json is absent and caches it' do + stub_request(:get, Contentstack::Endpoint::REGISTRY_URL) + .to_return(status: 200, body: regions_data.to_json, headers: {}) + + allow(File).to receive(:exist?).and_call_original + allow(File).to receive(:exist?).with(Contentstack::Endpoint::DATA_FILE_PATH).and_return(false) + allow(File).to receive(:write).and_call_original + + result = described_class.get_contentstack_endpoint('eu') + expect(result).to eq 'https://eu-cdn.contentstack.com' + end + end + + # --------------------------------------------------------------------------- + # refresh_regions + # --------------------------------------------------------------------------- + describe '.refresh_regions' do + it 'writes updated region data to DATA_FILE_PATH' do + stub_request(:get, Contentstack::Endpoint::REGISTRY_URL) + .to_return(status: 200, body: regions_data.to_json, headers: {}) + + allow(FileUtils).to receive(:mkdir_p) + expect(File).to receive(:write).with( + Contentstack::Endpoint::DATA_FILE_PATH, + JSON.pretty_generate(regions_data) + ) + + result = described_class.refresh_regions + expect(result).to eq regions_data + end + + it 'raises Contentstack::Error when registry returns non-200' do + stub_request(:get, Contentstack::Endpoint::REGISTRY_URL) + .to_return(status: 503, body: 'Service Unavailable') + + expect { described_class.refresh_regions } + .to raise_error(Contentstack::Error, /HTTP 503/) + end + end + + # --------------------------------------------------------------------------- + # Module-level proxy: Contentstack.get_contentstack_endpoint + # --------------------------------------------------------------------------- + describe 'Contentstack.get_contentstack_endpoint' do + it 'proxies to Endpoint and returns the correct CDA URL for US' do + expect(Contentstack.get_contentstack_endpoint('us')).to eq 'https://cdn.contentstack.io' + end + + it 'proxies to Endpoint and returns the correct CMA URL for EU' do + expect(Contentstack.get_contentstack_endpoint('eu', 'cma')).to eq 'https://eu-api.contentstack.com' + end + end +end