diff --git a/.gitignore b/.gitignore index 0df012b..dfb3f6a 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,6 @@ coverage .DS_Store .bundle/ **/rspec_results.html -.dccache \ No newline at end of file +.dccache +vendor/ +lib/contentstack_utils/assets/regions.json \ No newline at end of file diff --git a/.ruby-version b/.ruby-version index 0aec50e..b9b3b0d 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.1.4 +3.3.11 diff --git a/CHANGELOG.md b/CHANGELOG.md index a356b0a..b7a4be2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [1.3.0](https://github.com/contentstack/contentstack-utils-ruby/tree/v1.3.0) (2026-06-15) + - Added `ContentstackUtils::Endpoint.get_contentstack_endpoint` for dynamic endpoint resolution based on region and service. + - Added `ContentstackUtils.get_contentstack_endpoint` as a backward-compatible proxy. + - Added `ContentstackUtils::Endpoint.refresh_regions` for manual region metadata refresh. + - Added runtime fallback to automatically download `regions.json` from the Contentstack Regions Registry when not present locally. + ## [1.2.4](https://github.com/contentstack/contentstack-utils-ruby/tree/v1.2.4) (2026-04-15) - Fixed Security issues. diff --git a/Gemfile.lock b/Gemfile.lock index 934e413..cee5d4c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -24,7 +24,7 @@ GEM public_suffix (>= 2.0.2, < 8.0) base64 (0.3.0) benchmark (0.5.0) - bigdecimal (4.1.1) + bigdecimal (4.1.2) concurrent-ruby (1.3.6) connection_pool (3.0.2) crack (1.0.1) @@ -38,21 +38,21 @@ GEM concurrent-ruby (~> 1.0) logger (1.7.0) minitest (5.27.0) - nokogiri (1.19.2-aarch64-linux-gnu) + nokogiri (1.19.3-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.19.2-aarch64-linux-musl) + nokogiri (1.19.3-aarch64-linux-musl) racc (~> 1.4) - nokogiri (1.19.2-arm-linux-gnu) + nokogiri (1.19.3-arm-linux-gnu) racc (~> 1.4) - nokogiri (1.19.2-arm-linux-musl) + nokogiri (1.19.3-arm-linux-musl) racc (~> 1.4) - nokogiri (1.19.2-arm64-darwin) + nokogiri (1.19.3-arm64-darwin) racc (~> 1.4) - nokogiri (1.19.2-x86_64-darwin) + nokogiri (1.19.3-x86_64-darwin) racc (~> 1.4) - nokogiri (1.19.2-x86_64-linux-gnu) + nokogiri (1.19.3-x86_64-linux-gnu) racc (~> 1.4) - nokogiri (1.19.2-x86_64-linux-musl) + nokogiri (1.19.3-x86_64-linux-musl) racc (~> 1.4) public_suffix (7.0.5) racc (1.8.1) @@ -84,7 +84,7 @@ GEM addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) - yard (0.9.42) + yard (0.9.44) PLATFORMS aarch64-linux-gnu diff --git a/lib/contentstack_utils/endpoint.rb b/lib/contentstack_utils/endpoint.rb new file mode 100644 index 0000000..822020c --- /dev/null +++ b/lib/contentstack_utils/endpoint.rb @@ -0,0 +1,96 @@ +require 'json' +require 'net/http' +require 'uri' + +module ContentstackUtils + module Endpoint + REGIONS_URL = 'https://artifacts.contentstack.com/regions.json' + REGIONS_FILE = File.expand_path('../assets/regions.json', __FILE__) + + @regions_data = nil + + class << self + def get_contentstack_endpoint(region: 'us', service: '', omit_https: false) + raise ArgumentError, 'Empty region provided' if region.nil? || region.to_s.strip.empty? + + normalized = region.to_s.strip.downcase + regions = load_regions + + region_row = find_region_by_id_or_alias(regions, normalized) + raise ArgumentError, "Invalid region: #{region}" if region_row.nil? + + endpoints = region_row['endpoints'] + + if service.nil? || service.to_s.strip.empty? + return omit_https ? strip_https_from_map(endpoints) : endpoints.dup + end + + url = endpoints[service.to_s] + raise ArgumentError, "Service \"#{service}\" not found for region \"#{region}\"" if url.nil? + + omit_https ? strip_https(url) : url + end + + def refresh_regions + download_and_save(REGIONS_FILE) + @regions_data = nil + load_regions + true + end + + def reset_cache + @regions_data = nil + end + + private + + def load_regions + return @regions_data if @regions_data + + unless File.exist?(REGIONS_FILE) + download_and_save(REGIONS_FILE) + end + + raw = File.read(REGIONS_FILE) + parsed = JSON.parse(raw) + raise RuntimeError, 'Invalid regions data: missing "regions" key' unless parsed.is_a?(Hash) && parsed['regions'] + + @regions_data = parsed['regions'] + rescue JSON::ParserError => e + raise RuntimeError, "Failed to parse regions data: #{e.message}" + rescue Errno::ENOENT => e + raise RuntimeError, "Failed to read regions file: #{e.message}" + end + + def download_and_save(dest) + uri = URI.parse(REGIONS_URL) + response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', open_timeout: 30, read_timeout: 30) do |http| + http.get(uri.request_uri) + end + + raise RuntimeError, "Failed to download regions: HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess) + + parsed = JSON.parse(response.body) + raise RuntimeError, 'Downloaded regions data is invalid' unless parsed.is_a?(Hash) && parsed['regions'] + + FileUtils.mkdir_p(File.dirname(dest)) + File.write(dest, JSON.pretty_generate(parsed)) + rescue StandardError => e + raise RuntimeError, "Failed to fetch region metadata: #{e.message}" + end + + def find_region_by_id_or_alias(regions, input) + regions.find { |r| r['id'] == input } || + regions.find { |r| r['alias']&.any? { |a| a.downcase == input } } + end + + def strip_https(url) + url.sub(%r{\Ahttps?://}, '') + end + + def strip_https_from_map(endpoints) + endpoints.transform_values { |url| strip_https(url) } + end + end + end +end diff --git a/lib/contentstack_utils/utils.rb b/lib/contentstack_utils/utils.rb index 7eec6ba..a2d7594 100644 --- a/lib/contentstack_utils/utils.rb +++ b/lib/contentstack_utils/utils.rb @@ -1,6 +1,7 @@ require_relative './model/options.rb' require_relative './model/metadata.rb' require_relative './support/helper.rb' +require_relative './endpoint.rb' require 'nokogiri' module ContentstackUtils @@ -136,6 +137,10 @@ def self.json_doc_to_html(node, options, callback) return nil end + def self.get_contentstack_endpoint(region: 'us', service: '', omit_https: false) + Endpoint.get_contentstack_endpoint(region: region, service: service, omit_https: omit_https) + end + module GQL include ContentstackUtils def self.json_to_html(content, options) diff --git a/lib/contentstack_utils/version.rb b/lib/contentstack_utils/version.rb index 2ac4815..1e3d5b6 100644 --- a/lib/contentstack_utils/version.rb +++ b/lib/contentstack_utils/version.rb @@ -1,3 +1,3 @@ module ContentstackUtils - VERSION = "1.2.4" + VERSION = "1.3.0" end diff --git a/spec/lib/endpoint_spec.rb b/spec/lib/endpoint_spec.rb new file mode 100644 index 0000000..c1b5bca --- /dev/null +++ b/spec/lib/endpoint_spec.rb @@ -0,0 +1,268 @@ +require 'spec_helper' + +RSpec.describe ContentstackUtils::Endpoint do + before(:each) do + described_class.reset_cache + end + + # ── Default region ────────────────────────────────────────────────────────── + + describe '.get_contentstack_endpoint with defaults' do + it 'returns a hash when no service is given' do + result = described_class.get_contentstack_endpoint + expect(result).to be_a(Hash) + end + + it 'default region includes contentDelivery and contentManagement' do + result = described_class.get_contentstack_endpoint + expect(result).to have_key('contentDelivery') + expect(result).to have_key('contentManagement') + end + + it 'default region contentDelivery is NA CDN' do + result = described_class.get_contentstack_endpoint + expect(result['contentDelivery']).to eq('https://cdn.contentstack.io') + end + end + + # ── NA aliases ────────────────────────────────────────────────────────────── + + describe 'NA region aliases' do + %w[na us aws-na aws_na NA US AWS-NA AWS_NA].each do |alias_val| + it "alias \"#{alias_val}\" resolves contentDelivery to NA CDN" do + result = described_class.get_contentstack_endpoint(region: alias_val, service: 'contentDelivery') + expect(result).to eq('https://cdn.contentstack.io') + end + end + end + + # ── All 7 regions – contentDelivery ───────────────────────────────────────── + + describe 'contentDelivery endpoints per region' do + { + 'na' => 'https://cdn.contentstack.io', + 'eu' => 'https://eu-cdn.contentstack.com', + 'au' => 'https://au-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 "region \"#{region}\" resolves contentDelivery to #{expected_url}" do + result = described_class.get_contentstack_endpoint(region: region, service: 'contentDelivery') + expect(result).to eq(expected_url) + end + end + end + + # ── All 7 regions – contentManagement ─────────────────────────────────────── + + describe 'contentManagement endpoints per region' do + { + 'na' => 'https://api.contentstack.io', + 'eu' => 'https://eu-api.contentstack.com', + 'au' => 'https://au-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 "region \"#{region}\" resolves contentManagement to #{expected_url}" do + result = described_class.get_contentstack_endpoint(region: region, service: 'contentManagement') + expect(result).to eq(expected_url) + end + end + end + + # ── Service keys present ───────────────────────────────────────────────────── + + describe 'all service keys present' do + let(:expected_services) do + %w[ + application contentDelivery contentManagement auth + graphqlDelivery preview graphqlPreview images assets + automate launch developerHub brandKit genAI + personalizeManagement personalizeEdge composableStudio + ] + end + + it 'EU region returns all expected service keys' do + result = described_class.get_contentstack_endpoint(region: 'eu') + expected_services.each do |svc| + expect(result).to have_key(svc), "expected key #{svc} to be present" + end + end + + it 'NA region additionally includes assetManagement' do + result = described_class.get_contentstack_endpoint(region: 'na') + expect(result).to have_key('assetManagement') + end + end + + # ── omit_https flag ────────────────────────────────────────────────────────── + + describe 'omit_https option' do + it 'strips https:// from a single service URL' do + result = described_class.get_contentstack_endpoint(region: 'na', service: 'contentDelivery', omit_https: true) + expect(result).to eq('cdn.contentstack.io') + end + + it 'strips https:// from all endpoints when no service given' do + result = described_class.get_contentstack_endpoint(region: 'na', omit_https: true) + result.each_value do |url| + expect(url).not_to start_with('https://') + expect(url).not_to start_with('http://') + end + end + + it 'retains https:// when omit_https is false (default)' do + result = described_class.get_contentstack_endpoint(region: 'na', service: 'contentDelivery', omit_https: false) + expect(result).to start_with('https://') + end + + it 'strips https:// from EU contentManagement' do + result = described_class.get_contentstack_endpoint(region: 'eu', service: 'contentManagement', omit_https: true) + expect(result).to eq('eu-api.contentstack.com') + end + end + + # ── Case-insensitive aliases ───────────────────────────────────────────────── + + describe 'case-insensitive alias resolution' do + it 'resolves uppercase AWS-NA alias' do + result = described_class.get_contentstack_endpoint(region: 'AWS-NA', service: 'contentDelivery') + expect(result).to eq('https://azure-na-cdn.contentstack.com').or eq('https://cdn.contentstack.io') + end + + it 'resolves azure_na alias' do + result = described_class.get_contentstack_endpoint(region: 'azure_na', service: 'contentDelivery') + expect(result).to eq('https://azure-na-cdn.contentstack.com') + end + + it 'resolves gcp_eu alias' do + result = described_class.get_contentstack_endpoint(region: 'gcp_eu', service: 'contentDelivery') + expect(result).to eq('https://gcp-eu-cdn.contentstack.com') + end + + it 'resolves AZURE-EU alias' do + result = described_class.get_contentstack_endpoint(region: 'AZURE-EU', service: 'contentDelivery') + expect(result).to eq('https://azure-eu-cdn.contentstack.com') + end + + it 'resolves GCP-NA alias' do + result = described_class.get_contentstack_endpoint(region: 'GCP-NA', service: 'contentDelivery') + expect(result).to eq('https://gcp-na-cdn.contentstack.com') + end + end + + # ── No-service returns full endpoints hash ─────────────────────────────────── + + describe 'full endpoints map returned when no service specified' do + it 'AU returns a hash with more than 1 entry' do + result = described_class.get_contentstack_endpoint(region: 'au') + expect(result).to be_a(Hash) + expect(result.size).to be > 1 + end + + it 'AU contentDelivery is correct in the map' do + result = described_class.get_contentstack_endpoint(region: 'au') + expect(result['contentDelivery']).to eq('https://au-cdn.contentstack.com') + end + + it 'returns a copy — mutations do not affect cached data' do + result = described_class.get_contentstack_endpoint(region: 'na') + result['contentDelivery'] = 'mutated' + fresh = described_class.get_contentstack_endpoint(region: 'na') + expect(fresh['contentDelivery']).to eq('https://cdn.contentstack.io') + end + end + + # ── Error cases ────────────────────────────────────────────────────────────── + + describe 'error cases' do + it 'raises ArgumentError for empty string region' do + expect { described_class.get_contentstack_endpoint(region: '') } + .to raise_error(ArgumentError, 'Empty region provided') + end + + it 'raises ArgumentError for whitespace-only region' do + expect { described_class.get_contentstack_endpoint(region: ' ') } + .to raise_error(ArgumentError, 'Empty region provided') + end + + it 'raises ArgumentError for unknown region' do + expect { described_class.get_contentstack_endpoint(region: 'invalid-region') } + .to raise_error(ArgumentError, 'Invalid region: invalid-region') + end + + it 'raises ArgumentError for unknown service' do + expect { described_class.get_contentstack_endpoint(region: 'na', service: 'unknownService') } + .to raise_error(ArgumentError, 'Service "unknownService" not found for region "na"') + end + + it 'raises ArgumentError for valid region but unknown service' do + expect { described_class.get_contentstack_endpoint(region: 'eu', service: 'nonExistentService') } + .to raise_error(ArgumentError, /Service "nonExistentService" not found/) + end + end + + # ── Additional service spot-checks ────────────────────────────────────────── + + describe 'additional service endpoints' do + it 'resolves auth endpoint for NA' do + result = described_class.get_contentstack_endpoint(region: 'na', service: 'auth') + expect(result).to eq('https://auth-api.contentstack.com') + end + + it 'resolves graphqlDelivery endpoint for EU' do + result = described_class.get_contentstack_endpoint(region: 'eu', service: 'graphqlDelivery') + expect(result).to eq('https://eu-graphql.contentstack.com') + end + + it 'resolves preview endpoint for azure-na' do + result = described_class.get_contentstack_endpoint(region: 'azure-na', service: 'preview') + expect(result).to eq('https://azure-na-rest-preview.contentstack.com') + end + + it 'resolves application endpoint for gcp-eu' do + result = described_class.get_contentstack_endpoint(region: 'gcp-eu', service: 'application') + expect(result).to eq('https://gcp-eu-app.contentstack.com') + end + end +end + +# ── Utils proxy ──────────────────────────────────────────────────────────────── + +RSpec.describe ContentstackUtils do + before(:each) do + ContentstackUtils::Endpoint.reset_cache + end + + describe '.get_contentstack_endpoint' do + it 'returns same value as Endpoint.get_contentstack_endpoint for default args' do + expect(described_class.get_contentstack_endpoint) + .to eq(ContentstackUtils::Endpoint.get_contentstack_endpoint) + end + + it 'proxy returns contentDelivery for NA' do + result = described_class.get_contentstack_endpoint(region: 'na', service: 'contentDelivery') + expect(result).to eq('https://cdn.contentstack.io') + end + + it 'proxy respects omit_https option' do + result = described_class.get_contentstack_endpoint(region: 'na', service: 'contentDelivery', omit_https: true) + expect(result).to eq('cdn.contentstack.io') + end + + it 'proxy returns full endpoints map when no service given' do + result = described_class.get_contentstack_endpoint(region: 'eu') + expect(result).to be_a(Hash) + expect(result['contentManagement']).to eq('https://eu-api.contentstack.com') + end + + it 'proxy raises ArgumentError for invalid region' do + expect { described_class.get_contentstack_endpoint(region: 'bad-region') } + .to raise_error(ArgumentError, /Invalid region/) + end + end +end