From ac48885509fc5a2078fce093e7ddbcd3415a7094 Mon Sep 17 00:00:00 2001 From: reeshika-h Date: Wed, 10 Jun 2026 15:04:21 +0530 Subject: [PATCH 1/4] Implement endpoint integration with Contentstack regions registry - Added `Endpoint.swift` to resolve Contentstack API endpoints for various regions and services. - Introduced `download-regions.sh` script to download and manage the regions registry. - Updated `Package.swift` to include resources for regions and test targets. - Enhanced `ContentstackUtils` with a proxy method for endpoint retrieval. - Created `EndpointTests.swift` to validate endpoint resolution and error handling. - Added `endpoint-info.json` for resource description. - Updated `.gitignore` to exclude downloaded regions.json. --- .gitignore | 5 +- ContentstackUtils.xcodeproj/project.pbxproj | 30 +++ Package.swift | 19 +- Scripts/download-regions.sh | 51 ++++ .../ContentstackUtils/ContentstackUtils.swift | 12 + Sources/ContentstackUtils/Endpoint.swift | 230 +++++++++++++++++ .../Resources/endpoint-info.json | 3 + .../EndpointTests.swift | 238 ++++++++++++++++++ 8 files changed, 583 insertions(+), 5 deletions(-) create mode 100755 Scripts/download-regions.sh create mode 100644 Sources/ContentstackUtils/Endpoint.swift create mode 100644 Sources/ContentstackUtils/Resources/endpoint-info.json create mode 100644 Tests/ContentstackUtilsTests/EndpointTests.swift diff --git a/.gitignore b/.gitignore index dc3d7df..b386c6d 100644 --- a/.gitignore +++ b/.gitignore @@ -77,4 +77,7 @@ TestCase /test-reports /TestCase /xcov_output -/html \ No newline at end of file +/html + +# Contentstack regions registry — downloaded by Scripts/download-regions.sh, never committed +Sources/ContentstackUtils/Resources/regions.json \ No newline at end of file diff --git a/ContentstackUtils.xcodeproj/project.pbxproj b/ContentstackUtils.xcodeproj/project.pbxproj index 46b7d86..e3f773c 100644 --- a/ContentstackUtils.xcodeproj/project.pbxproj +++ b/ContentstackUtils.xcodeproj/project.pbxproj @@ -68,6 +68,10 @@ 6749AC902F714E26007282C5 /* variantsEntries.json in Resources */ = {isa = PBXBuildFile; fileRef = 6749AC8F2F714E26007282C5 /* variantsEntries.json */; }; 6749AC922F714E2F007282C5 /* variantsSingleEntry.json in Resources */ = {isa = PBXBuildFile; fileRef = 6749AC912F714E2F007282C5 /* variantsSingleEntry.json */; }; 6749AC942F714E36007282C5 /* VariantUtilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6749AC932F714E36007282C5 /* VariantUtilityTests.swift */; }; + 679382562FD96042007C4158 /* Endpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 679382552FD96042007C4158 /* Endpoint.swift */; }; + 679382572FD96042007C4158 /* Endpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 679382552FD96042007C4158 /* Endpoint.swift */; }; + 679382582FD96042007C4158 /* Endpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 679382552FD96042007C4158 /* Endpoint.swift */; }; + 6793825B2FD9606B007C4158 /* EndpointTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6793825A2FD9606B007C4158 /* EndpointTests.swift */; }; OBJ_22 /* ContentstackUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_9 /* ContentstackUtils.swift */; }; OBJ_29 /* Package.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_6 /* Package.swift */; }; OBJ_40 /* ContentstackUtilsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_12 /* ContentstackUtilsTests.swift */; }; @@ -141,10 +145,15 @@ 0FFF2F292668FC54003E9DBF /* NodeType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeType.swift; sourceTree = ""; }; 0FFF2F372668FE85003E9DBF /* Node.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Node.swift; sourceTree = ""; }; 64F522122BF5F3F300AE6E0F /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; + 670DAEA12FD9637200FB27D9 /* TestPlan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; name = TestPlan.xctestplan; path = Tests/ContentstackUtilsTests/TestPlan.xctestplan; sourceTree = ""; }; 6749AC8F2F714E26007282C5 /* variantsEntries.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = variantsEntries.json; sourceTree = ""; }; 6749AC912F714E2F007282C5 /* variantsSingleEntry.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = variantsSingleEntry.json; sourceTree = ""; }; 6749AC932F714E36007282C5 /* VariantUtilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VariantUtilityTests.swift; sourceTree = ""; }; 6749AC952F715507007282C5 /* CHANGELOG.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = CHANGELOG.md; sourceTree = ""; }; + 679382552FD96042007C4158 /* Endpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Endpoint.swift; sourceTree = ""; }; + 679382592FD9604A007C4158 /* ContentstackUtilsPackageTests-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "ContentstackUtilsPackageTests-Bridging-Header.h"; sourceTree = ""; }; + 6793825A2FD9606B007C4158 /* EndpointTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EndpointTests.swift; sourceTree = ""; }; + 6793825C2FD96097007C4158 /* download-regions.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "download-regions.sh"; sourceTree = ""; }; "ContentstackUtils::ContentstackUtils::Product" /* ContentstackUtils.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = ContentstackUtils.framework; sourceTree = BUILT_PRODUCTS_DIR; }; "ContentstackUtils::ContentstackUtilsTests::Product" /* ContentstackUtilsTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; path = ContentstackUtilsTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; OBJ_12 /* ContentstackUtilsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentstackUtilsTests.swift; sourceTree = ""; }; @@ -193,11 +202,13 @@ children = ( 0FEC37AF254FFF6E00B1EFDD /* Metadata.swift */, 0F00785C26A6ACBF00FC4925 /* GQLEmbededEntry.swift */, + 679382552FD96042007C4158 /* Endpoint.swift */, 0F00785E26A6ACDC00FC4925 /* GQLEmbededAsset.swift */, 0F00786026A6AD0100FC4925 /* JSONNode.swift */, 0F00786226A6AD2100FC4925 /* JSONNodes.swift */, 0F00786426A6AD3E00FC4925 /* Edges.swift */, 0F00786626A6AD6800FC4925 /* ConnectionNode.swift */, + 679382592FD9604A007C4158 /* ContentstackUtilsPackageTests-Bridging-Header.h */, ); name = Models; sourceTree = ""; @@ -243,6 +254,7 @@ isa = PBXGroup; children = ( 0FA3D58F252228E300E58179 /* build.sh */, + 6793825C2FD96097007C4158 /* download-regions.sh */, 0FA3D5902522290700E58179 /* run-test-case.sh */, ); path = Scripts; @@ -333,6 +345,7 @@ 6749AC932F714E36007282C5 /* VariantUtilityTests.swift */, 0F7142C325514A6F00C18A61 /* ContentstackUtilsArrayTest.swift */, 0F7142C52551684600C18A61 /* ContentstackUtilsCustomRendertest.swift */, + 6793825A2FD9606B007C4158 /* EndpointTests.swift */, 0F579540266A50D40082815C /* MarkTypeTest.swift */, 0F579546266A50E30082815C /* NodeTypeTest.swift */, 0FFD88D6266DDD1900BA5919 /* ContentstackUtilsJsonToHtmlTest.swift */, @@ -354,6 +367,7 @@ OBJ_5 = { isa = PBXGroup; children = ( + 670DAEA12FD9637200FB27D9 /* TestPlan.xctestplan */, 64F522122BF5F3F300AE6E0F /* PrivacyInfo.xcprivacy */, 0FAA3EBD26A1C65B00173FA9 /* ContentstackUtils.podspec */, OBJ_6 /* Package.swift */, @@ -454,6 +468,11 @@ BuildIndependentTargetsInParallel = YES; LastSwiftMigration = 9999; LastUpgradeCheck = 1620; + TargetAttributes = { + "ContentstackUtils::ContentstackUtilsPackageTests::ProductTarget" = { + LastSwiftMigration = 2620; + }; + }; }; buildConfigurationList = OBJ_2 /* Build configuration list for PBXProject "ContentstackUtils" */; compatibilityVersion = "Xcode 3.2"; @@ -540,6 +559,7 @@ 0F00786326A6AD2100FC4925 /* JSONNodes.swift in Sources */, 0F00785D26A6ACBF00FC4925 /* GQLEmbededEntry.swift in Sources */, OBJ_22 /* ContentstackUtils.swift in Sources */, + 679382562FD96042007C4158 /* Endpoint.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -547,6 +567,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 0; files = ( + 679382582FD96042007C4158 /* Endpoint.swift in Sources */, OBJ_29 /* Package.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -565,6 +586,7 @@ 0FEC37BA25503E5000B1EFDD /* CustomRenderOptionMock.swift in Sources */, 0FA3D58A252207B000E58179 /* DefaultRenderTests.swift in Sources */, 0FEC0B3B254FEC60008D4E66 /* MetadataTests.swift in Sources */, + 6793825B2FD9606B007C4158 /* EndpointTests.swift in Sources */, 0FA3D58D2522098000E58179 /* EmbededModelMock.swift in Sources */, 0FFD88D7266DDD1900BA5919 /* ContentstackUtilsJsonToHtmlTest.swift in Sources */, 0F07E62F25244DB5003E0BD1 /* StringExtensionTests.swift in Sources */, @@ -572,6 +594,7 @@ 6749AC942F714E36007282C5 /* VariantUtilityTests.swift in Sources */, 0FFD88EE266DE1A600BA5919 /* NodeParser.swift in Sources */, 0FFD88F7266DE1FB00BA5919 /* JsonNodes.swift in Sources */, + 679382572FD96042007C4158 /* Endpoint.swift in Sources */, 0F00785B26A5A0EB00FC4925 /* GQLJsonToHtml.swift in Sources */, 0F00785926A59D6600FC4925 /* GQLJsonRTE.swift in Sources */, OBJ_41 /* XCTestManifests.swift in Sources */, @@ -756,14 +779,21 @@ OBJ_32 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { + CLANG_ENABLE_MODULES = YES; DEAD_CODE_STRIPPING = YES; + SWIFT_OBJC_BRIDGING_HEADER = "Sources/ContentstackUtils/ContentstackUtilsPackageTests-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 6.0; }; name = Debug; }; OBJ_33 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { + CLANG_ENABLE_MODULES = YES; DEAD_CODE_STRIPPING = YES; + SWIFT_OBJC_BRIDGING_HEADER = "Sources/ContentstackUtils/ContentstackUtilsPackageTests-Bridging-Header.h"; + SWIFT_VERSION = 6.0; }; name = Release; }; diff --git a/Package.swift b/Package.swift index 70920cd..e631258 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.1 +// swift-tools-version:5.3 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -53,11 +53,22 @@ let package = Package( .target( name: "ContentstackUtils", dependencies: dependencies, - path: "Sources"), + path: "Sources", + resources: [ + // regions.json is downloaded by Scripts/download-regions.sh and is NOT committed. + // SPM bundles it when present; Endpoint falls back to HTTP download when absent. + .process("ContentstackUtils/Resources") + ] + ), .testTarget( name: "ContentstackUtilsTests", - dependencies: ["ContentstackUtils"]), - + dependencies: ["ContentstackUtils"], + resources: [ + .process("EntryEmbedded.json"), + .process("variantsEntries.json"), + .process("variantsSingleEntry.json") + ]), + ], swiftLanguageVersions: [.v5] ) diff --git a/Scripts/download-regions.sh b/Scripts/download-regions.sh new file mode 100755 index 0000000..657129d --- /dev/null +++ b/Scripts/download-regions.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# +# Downloads the Contentstack regions registry from the official source and +# saves it to Sources/ContentstackUtils/Resources/regions.json. +# +# Run before building so SPM bundles the file into the module: +# bash Scripts/download-regions.sh && swift build +# +# Also invoked manually to refresh the cached data: +# bash Scripts/download-regions.sh +# +# Requires: curl (preferred) or wget as fallback + +set -euo pipefail + +URL="https://artifacts.contentstack.com/regions.json" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DEST="${SCRIPT_DIR}/../Sources/ContentstackUtils/Resources/regions.json" +DIR="$(dirname "$DEST")" + +mkdir -p "$DIR" + +data="" + +# --- Attempt 1: curl (preferred) -------------------------------------------- +if command -v curl &>/dev/null; then + data=$(curl --silent --fail --location --max-time 30 "$URL") || data="" +fi + +# --- Attempt 2: wget fallback ----------------------------------------------- +if [[ -z "$data" ]] && command -v wget &>/dev/null; then + data=$(wget --quiet --timeout=30 -O - "$URL") || data="" +fi + +# --- Validate and write ------------------------------------------------------ +if [[ -z "$data" ]]; then + echo "contentstack/utils: Warning — could not download regions.json." >&2 + echo " The SDK will attempt to download it at runtime on first use." >&2 + exit 0 # non-fatal: runtime fallback in Endpoint.swift handles it +fi + +# Basic validation: must contain a "regions" key +if ! echo "$data" | grep -q '"regions"'; then + echo "contentstack/utils: Warning — downloaded data is not valid regions.json." >&2 + exit 0 +fi + +echo "$data" > "$DEST" + +region_count=$(echo "$data" | grep -o '"id"' | wc -l | tr -d ' ') +echo "contentstack/utils: regions.json downloaded (${region_count} regions)." diff --git a/Sources/ContentstackUtils/ContentstackUtils.swift b/Sources/ContentstackUtils/ContentstackUtils.swift index 22ce529..157b378 100644 --- a/Sources/ContentstackUtils/ContentstackUtils.swift +++ b/Sources/ContentstackUtils/ContentstackUtils.swift @@ -150,6 +150,18 @@ public struct ContentstackUtils { } } + /// Proxy for `Endpoint.getContentstackEndpoint(_:_:_:)`. + /// Both calls produce identical results; this exists so callers already using + /// `ContentstackUtils.` don't need to change their import. + @discardableResult + public static func getContentstackEndpoint( + _ region: String = "us", + _ service: String = "", + _ omitHttps: Bool = false + ) throws -> Any { + return try Endpoint.getContentstackEndpoint(region, service, omitHttps) + } + private static func jsonString(for array: [[String: Any]]) throws -> String{ let data = try JSONSerialization.data(withJSONObject: array, options: []) guard let json = String(data: data, encoding: .utf8) else { diff --git a/Sources/ContentstackUtils/Endpoint.swift b/Sources/ContentstackUtils/Endpoint.swift new file mode 100644 index 0000000..6625cf7 --- /dev/null +++ b/Sources/ContentstackUtils/Endpoint.swift @@ -0,0 +1,230 @@ +import Foundation + +/// Resolves Contentstack API endpoints for any region and service. +/// +/// ## Data loading — three layers +/// +/// 1. **In-memory cache** — zero I/O; lives for the process lifetime. +/// 2. **Bundled `regions.json`** — present when `Scripts/download-regions.sh` was run before +/// building. After serving from this layer, a background network refresh fires so the cache +/// stays current without blocking the caller. +/// 3. **Live HTTP download** — fallback when the bundle resource is absent (e.g. fresh +/// dependency install without running the script). +/// +/// The background refresh means: once the first call returns, subsequent calls within the same +/// process will automatically use the latest Contentstack region data, even if the bundled file +/// was built from an older snapshot. +/// +/// ```swift +/// // Full URL +/// let url = try Endpoint.getContentstackEndpoint("eu", "contentDelivery") +/// // "https://eu-cdn.contentstack.com" +/// +/// // Host only (for SDK setHost) +/// let host = try Endpoint.getContentstackEndpoint("eu", "contentDelivery", true) +/// // "eu-cdn.contentstack.com" +/// +/// // All endpoints for a region +/// let all = try Endpoint.getContentstackEndpoint("eu") as! [String: String] +/// ``` +public struct Endpoint { + + // Search all loaded bundles for regions.json. Works in SPM (swift test/build), + // Xcode Package scheme, and Xcode framework builds without relying on Bundle.module, + // which is only synthesised when the SPM command-line build system processes Package.swift. + private static func bundledRegionsURL() -> URL? { + (Bundle.allBundles + Bundle.allFrameworks) + .lazy + .compactMap { $0.url(forResource: "regions", withExtension: "json") } + .first + } + + // MARK: - Error type + + public enum EndpointError: LocalizedError { + case emptyRegion + case invalidRegion(String) + case serviceNotFound(String, String) + case regionsDataUnavailable + case invalidRegionsData + + public var errorDescription: String? { + switch self { + case .emptyRegion: + return "Empty region provided. Please put valid region." + case .invalidRegion(let r): + return "Invalid region: \(r)" + case .serviceNotFound(let s, let r): + return "Service \"\(s)\" not found for region \"\(r)\"" + case .regionsDataUnavailable: + return "contentstack/utils: regions.json is unavailable and could not be downloaded. " + + "Run Scripts/download-regions.sh or ensure network access." + case .invalidRegionsData: + return "contentstack/utils: regions.json is corrupt or invalid. " + + "Run Scripts/download-regions.sh to re-download it." + } + } + } + + // MARK: - Thread-safe cache + + private static let lock = NSLock() + private static var _cachedRegions: [[String: Any]]? = nil + private static var _refreshTask: URLSessionDataTask? = nil + + private static var cachedRegions: [[String: Any]]? { + get { lock.lock(); defer { lock.unlock() }; return _cachedRegions } + set { lock.lock(); defer { lock.unlock() }; _cachedRegions = newValue } + } + + private static let regionsURL = "https://artifacts.contentstack.com/regions.json" + + // MARK: - Public API + + /// Returns the Contentstack endpoint for the given region and service. + /// + /// - Parameters: + /// - region: Region ID or alias (e.g. `"na"`, `"us"`, `"eu"`, `"azure-na"`). Case-insensitive. + /// - service: Service key (e.g. `"contentDelivery"`, `"contentManagement"`). + /// Pass an empty string (the default) to receive all endpoints as `[String: String]`. + /// - omitHttps: When `true`, strips `"https://"` from every returned URL. + /// - Returns: `String` for a specific service; `[String: String]` when `service` is empty. + /// - Throws: `Endpoint.EndpointError` for invalid input, unavailable data, or corrupt data. + @discardableResult + public static func getContentstackEndpoint( + _ region: String = "us", + _ service: String = "", + _ omitHttps: Bool = false + ) throws -> Any { + let trimmed = region.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { throw EndpointError.emptyRegion } + + let regions = try loadRegions() + let normalized = trimmed.lowercased() + + guard let row = findRegion(in: regions, normalized: normalized) else { + throw EndpointError.invalidRegion(trimmed) + } + + guard let endpoints = row["endpoints"] as? [String: String] else { + throw EndpointError.invalidRegionsData + } + + if service.isEmpty { + return omitHttps + ? endpoints.mapValues { stripScheme($0) } + : endpoints + } + + guard let url = endpoints[service] else { + throw EndpointError.serviceNotFound(service, trimmed) + } + + return omitHttps ? stripScheme(url) : url + } + + // MARK: - Internal (test support) + + static func resetCache() { + lock.lock() + defer { lock.unlock() } + _cachedRegions = nil + _refreshTask?.cancel() + _refreshTask = nil + } + + /// Parses a JSON string and seeds the in-memory cache. Used by tests to avoid network calls. + static func seedCache(fromJSON jsonString: String) throws { + guard let data = jsonString.data(using: .utf8), + let regions = parseRegions(from: data) else { + throw EndpointError.invalidRegionsData + } + cachedRegions = regions + } + + // MARK: - Private loading + + private static func loadRegions() throws -> [[String: Any]] { + // Layer 1 — in-memory cache (zero I/O) + if let cached = cachedRegions { return cached } + + // Layer 2 — bundled file (present when Scripts/download-regions.sh was run before build) + if let bundleURL = bundledRegionsURL(), + let regions = parseRegions(from: try Data(contentsOf: bundleURL)) { + cachedRegions = regions + // After serving bundled data, refresh in the background so the cache + // picks up any new regions or URLs added since the last build. + scheduleBackgroundRefresh() + return regions + } + + // Layer 3 — live HTTP download (blocking; used when bundle resource is absent) + guard let remoteURL = URL(string: regionsURL), + let data = try? Data(contentsOf: remoteURL) else { + throw EndpointError.regionsDataUnavailable + } + guard let regions = parseRegions(from: data) else { + throw EndpointError.invalidRegionsData + } + cachedRegions = regions + return regions + } + + /// Fires a one-shot background URLSession task that updates the in-memory cache. + /// Subsequent calls to `getContentstackEndpoint` within the same process will use the + /// refreshed data once the task completes — without ever blocking the caller. + private static func scheduleBackgroundRefresh() { + lock.lock() + guard _refreshTask == nil else { lock.unlock(); return } + lock.unlock() + + guard let url = URL(string: regionsURL) else { return } + let task = URLSession.shared.dataTask(with: url) { data, _, _ in + lock.lock() + _refreshTask = nil + lock.unlock() + guard let data = data, let regions = parseRegions(from: data) else { return } + cachedRegions = regions + } + + lock.lock() + _refreshTask = task + lock.unlock() + + task.resume() + } + + // MARK: - Private helpers + + private static func parseRegions(from data: Data) -> [[String: Any]]? { + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let regions = json["regions"] as? [[String: Any]] else { + return nil + } + return regions + } + + private static func findRegion(in regions: [[String: Any]], normalized: String) -> [String: Any]? { + // Pass 1: canonical id + for region in regions { + if let id = region["id"] as? String, id.lowercased() == normalized { + return region + } + } + // Pass 2: aliases + for region in regions { + if let aliases = region["alias"] as? [String] { + for alias in aliases where alias.lowercased() == normalized { + return region + } + } + } + return nil + } + + private static func stripScheme(_ url: String) -> String { + if url.hasPrefix("https://") { return String(url.dropFirst(8)) } + if url.hasPrefix("http://") { return String(url.dropFirst(7)) } + return url + } +} diff --git a/Sources/ContentstackUtils/Resources/endpoint-info.json b/Sources/ContentstackUtils/Resources/endpoint-info.json new file mode 100644 index 0000000..1dbf972 --- /dev/null +++ b/Sources/ContentstackUtils/Resources/endpoint-info.json @@ -0,0 +1,3 @@ +{ + "description": "Contentstack endpoint resources — regions.json is downloaded here by Scripts/download-regions.sh" +} diff --git a/Tests/ContentstackUtilsTests/EndpointTests.swift b/Tests/ContentstackUtilsTests/EndpointTests.swift new file mode 100644 index 0000000..7e29731 --- /dev/null +++ b/Tests/ContentstackUtilsTests/EndpointTests.swift @@ -0,0 +1,238 @@ +import XCTest +@testable import ContentstackUtils + +// Regions data is loaded once per test run via the real loading path: +// 1. bundled regions.json (present after `bash Scripts/download-regions.sh && swift build`) +// 2. live HTTP download (fallback when bundle resource is absent) +// Individual tests share the in-memory cache — no fake seeding, no per-test network calls. + +final class EndpointTests: XCTestCase { + + override class func setUp() { + super.setUp() + Endpoint.resetCache() + } + + // MARK: - Basic resolution + + func testNAContentDeliveryFullURL() throws { + let url = try Endpoint.getContentstackEndpoint("na", "contentDelivery") as! String + XCTAssertEqual(url, "https://cdn.contentstack.io") + } + + func testNAContentDeliveryOmitHttps() throws { + let host = try Endpoint.getContentstackEndpoint("na", "contentDelivery", true) as! String + XCTAssertEqual(host, "cdn.contentstack.io") + } + + func testEUContentDelivery() throws { + let url = try Endpoint.getContentstackEndpoint("eu", "contentDelivery") as! String + XCTAssertEqual(url, "https://eu-cdn.contentstack.com") + } + + func testAUContentManagement() throws { + let url = try Endpoint.getContentstackEndpoint("au", "contentManagement") as! String + XCTAssertEqual(url, "https://au-api.contentstack.com") + } + + func testAzureNAContentDelivery() throws { + let url = try Endpoint.getContentstackEndpoint("azure-na", "contentDelivery") as! String + XCTAssertEqual(url, "https://azure-na-cdn.contentstack.com") + } + + func testAzureEUGraphQL() throws { + let url = try Endpoint.getContentstackEndpoint("azure-eu", "graphqlDelivery") as! String + XCTAssertEqual(url, "https://azure-eu-graphql.contentstack.com") + } + + func testGCPNAContentDelivery() throws { + let url = try Endpoint.getContentstackEndpoint("gcp-na", "contentDelivery") as! String + XCTAssertEqual(url, "https://gcp-na-cdn.contentstack.com") + } + + func testGCPEUAuth() throws { + let url = try Endpoint.getContentstackEndpoint("gcp-eu", "auth") as! String + XCTAssertEqual(url, "https://gcp-eu-auth-api.contentstack.com") + } + + // MARK: - Region aliases + + func testAliasUS() throws { + let url = try Endpoint.getContentstackEndpoint("us", "contentDelivery") as! String + XCTAssertEqual(url, "https://cdn.contentstack.io") + } + + func testAliasAWSNA() throws { + let url = try Endpoint.getContentstackEndpoint("aws-na", "contentDelivery") as! String + XCTAssertEqual(url, "https://cdn.contentstack.io") + } + + func testAliasAWSNAUnderscore() throws { + let url = try Endpoint.getContentstackEndpoint("aws_na", "contentDelivery") as! String + XCTAssertEqual(url, "https://cdn.contentstack.io") + } + + func testAliasUppercaseUS() throws { + let url = try Endpoint.getContentstackEndpoint("US", "contentDelivery") as! String + XCTAssertEqual(url, "https://cdn.contentstack.io") + } + + func testAliasUppercaseAWSNA() throws { + let url = try Endpoint.getContentstackEndpoint("AWS-NA", "contentDelivery") as! String + XCTAssertEqual(url, "https://cdn.contentstack.io") + } + + func testAliasUppercaseAWSNAUnderscore() throws { + let url = try Endpoint.getContentstackEndpoint("AWS_NA", "contentDelivery") as! String + XCTAssertEqual(url, "https://cdn.contentstack.io") + } + + func testAliasEU() throws { + let url = try Endpoint.getContentstackEndpoint("EU", "contentDelivery") as! String + XCTAssertEqual(url, "https://eu-cdn.contentstack.com") + } + + func testAliasAWSEUUnderscore() throws { + let url = try Endpoint.getContentstackEndpoint("aws_eu", "contentDelivery") as! String + XCTAssertEqual(url, "https://eu-cdn.contentstack.com") + } + + func testAliasAzureNAUnderscore() throws { + let url = try Endpoint.getContentstackEndpoint("azure_na", "contentDelivery") as! String + XCTAssertEqual(url, "https://azure-na-cdn.contentstack.com") + } + + func testAliasAzureEUUppercase() throws { + let url = try Endpoint.getContentstackEndpoint("AZURE-EU", "contentDelivery") as! String + XCTAssertEqual(url, "https://azure-eu-cdn.contentstack.com") + } + + func testAliasGCPNAUnderscore() throws { + let url = try Endpoint.getContentstackEndpoint("gcp_na", "contentDelivery") as! String + XCTAssertEqual(url, "https://gcp-na-cdn.contentstack.com") + } + + func testAliasGCPEUUppercase() throws { + let url = try Endpoint.getContentstackEndpoint("GCP-EU", "contentDelivery") as! String + XCTAssertEqual(url, "https://gcp-eu-cdn.contentstack.com") + } + + // MARK: - omitHttps + + func testOmitHttpsEUContentManagement() throws { + let host = try Endpoint.getContentstackEndpoint("eu", "contentManagement", true) as! String + XCTAssertEqual(host, "eu-api.contentstack.com") + } + + func testOmitHttpsGCPNAContentManagement() throws { + let host = try Endpoint.getContentstackEndpoint("gcp-na", "contentManagement", true) as! String + XCTAssertEqual(host, "gcp-na-api.contentstack.com") + } + + // MARK: - All endpoints (no service) + + func testAllEndpointsForNA() throws { + let all = try Endpoint.getContentstackEndpoint("na") as! [String: String] + XCTAssertEqual(all["contentDelivery"], "https://cdn.contentstack.io") + XCTAssertEqual(all["contentManagement"], "https://api.contentstack.io") + XCTAssertEqual(all["auth"], "https://auth-api.contentstack.com") + XCTAssertEqual(all["assetManagement"], "https://am-api.contentstack.com") + } + + func testAllEndpointsForEU() throws { + let all = try Endpoint.getContentstackEndpoint("eu") as! [String: String] + XCTAssertEqual(all["contentDelivery"], "https://eu-cdn.contentstack.com") + XCTAssertFalse(all.keys.contains("assetManagement"), "EU should not have assetManagement") + } + + func testAllEndpointsOmitHttps() throws { + let all = try Endpoint.getContentstackEndpoint("eu", "", true) as! [String: String] + XCTAssertEqual(all["contentDelivery"], "eu-cdn.contentstack.com") + XCTAssertEqual(all["contentManagement"], "eu-api.contentstack.com") + for value in all.values { + XCTAssertFalse(value.hasPrefix("https://"), "omitHttps should strip scheme from all values") + } + } + + // MARK: - ContentstackUtils proxy + + func testProxyMatchesEndpoint() throws { + let direct = try Endpoint.getContentstackEndpoint("na", "contentDelivery") as! String + let proxy = try ContentstackUtils.getContentstackEndpoint("na", "contentDelivery") as! String + XCTAssertEqual(direct, proxy) + } + + // MARK: - Error cases + + func testEmptyRegionThrows() { + XCTAssertThrowsError(try Endpoint.getContentstackEndpoint("", "contentDelivery")) { error in + guard let e = error as? Endpoint.EndpointError, + case .emptyRegion = e else { + return XCTFail("Expected EndpointError.emptyRegion, got \(error)") + } + } + } + + func testWhitespaceOnlyRegionThrows() { + XCTAssertThrowsError(try Endpoint.getContentstackEndpoint(" ", "contentDelivery")) { error in + guard let e = error as? Endpoint.EndpointError, + case .emptyRegion = e else { + return XCTFail("Expected EndpointError.emptyRegion, got \(error)") + } + } + } + + func testInvalidRegionThrows() { + XCTAssertThrowsError(try Endpoint.getContentstackEndpoint("asia-pacific", "contentDelivery")) { error in + guard let e = error as? Endpoint.EndpointError, + case .invalidRegion(let r) = e else { + return XCTFail("Expected EndpointError.invalidRegion, got \(error)") + } + XCTAssertEqual(r, "asia-pacific") + } + } + + func testUnknownServiceThrows() { + XCTAssertThrowsError(try Endpoint.getContentstackEndpoint("na", "cms")) { error in + guard let e = error as? Endpoint.EndpointError, + case .serviceNotFound(let s, let r) = e else { + return XCTFail("Expected EndpointError.serviceNotFound, got \(error)") + } + XCTAssertEqual(s, "cms") + XCTAssertEqual(r, "na") + } + } + + func testServiceNotAvailableInRegion() { + // assetManagement is NA-only; requesting it for EU should throw + XCTAssertThrowsError(try Endpoint.getContentstackEndpoint("eu", "assetManagement")) { error in + guard let e = error as? Endpoint.EndpointError, + case .serviceNotFound = e else { + return XCTFail("Expected EndpointError.serviceNotFound, got \(error)") + } + } + } + + // MARK: - Whitespace trimming + + func testRegionWithLeadingTrailingSpaces() throws { + let url = try Endpoint.getContentstackEndpoint(" na ", "contentDelivery") as! String + XCTAssertEqual(url, "https://cdn.contentstack.io") + } + + // MARK: - Cache behaviour + + func testCacheIsUsedOnSecondCall() throws { + _ = try Endpoint.getContentstackEndpoint("na", "contentDelivery") + let url = try Endpoint.getContentstackEndpoint("na", "contentDelivery") as! String + XCTAssertEqual(url, "https://cdn.contentstack.io") + } + + func testResetCacheAndReload() throws { + _ = try Endpoint.getContentstackEndpoint("na", "contentDelivery") + Endpoint.resetCache() + // After reset the next call reloads from bundle or HTTP — result must still be correct + let url = try Endpoint.getContentstackEndpoint("na", "contentDelivery") as! String + XCTAssertEqual(url, "https://cdn.contentstack.io") + } +} From 2f6f449bbe7771a11aef42274adbf0a88dedb9bb Mon Sep 17 00:00:00 2001 From: reeshika-h Date: Fri, 26 Jun 2026 12:48:08 +0530 Subject: [PATCH 2/4] version bump --- CHANGELOG.md | 6 ++++++ .../xcschemes/ContentstackUtils.xcscheme | 16 +++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e0a4088..85f937e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ All notable changes to this project will be documented in this file. - 2026-04-02: Added CocoaPods deprecation guidance for **new** projects—**SPM** is recommended for ContentstackUtils; clarified companion role vs the core Swift CDA SDK. Updated README (Important section), added root **DEPRECATION.md** (customer-facing only), added **Docs/overview.md** banner with link to `DEPRECATION.md`. +## [1.6.0] - 2026-06-26 + +### Added + +- **Region endpoint resolution** — `Endpoint.getContentstackEndpoint` resolves service endpoints dynamically from the Contentstack Regions Registry (alias-aware, case-insensitive), with `Endpoint.EndpointError` on invalid input. + ## [1.5.0] - 2026-03-31 - **`getVariantMetadataTags`** is the canonical API for `data-csvariants`; **`getDataCsvariantsAttribute`** is deprecated (delegates to it until removed in a major release). diff --git a/ContentstackUtils.xcodeproj/xcshareddata/xcschemes/ContentstackUtils.xcscheme b/ContentstackUtils.xcodeproj/xcshareddata/xcschemes/ContentstackUtils.xcscheme index 1fdd166..c9b244c 100644 --- a/ContentstackUtils.xcodeproj/xcshareddata/xcschemes/ContentstackUtils.xcscheme +++ b/ContentstackUtils.xcodeproj/xcshareddata/xcschemes/ContentstackUtils.xcscheme @@ -1,7 +1,7 @@ + version = "1.7"> @@ -28,6 +28,20 @@ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES"> + + + + + + Date: Fri, 26 Jun 2026 15:19:11 +0530 Subject: [PATCH 3/4] Update CHANGELOG for version 1.6.0: correct release date and change 'Added' to 'Enhancement' --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85f937e..7f4134c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,9 @@ All notable changes to this project will be documented in this file. - 2026-04-02: Added CocoaPods deprecation guidance for **new** projects—**SPM** is recommended for ContentstackUtils; clarified companion role vs the core Swift CDA SDK. Updated README (Important section), added root **DEPRECATION.md** (customer-facing only), added **Docs/overview.md** banner with link to `DEPRECATION.md`. -## [1.6.0] - 2026-06-26 +## [1.6.0] - 2026-06-29 -### Added +### Enhancement - **Region endpoint resolution** — `Endpoint.getContentstackEndpoint` resolves service endpoints dynamically from the Contentstack Regions Registry (alias-aware, case-insensitive), with `Endpoint.EndpointError` on invalid input. From 412dafb37cb895ca1449aba0f1858f687e008224 Mon Sep 17 00:00:00 2001 From: reeshika-h Date: Fri, 26 Jun 2026 15:28:03 +0530 Subject: [PATCH 4/4] Update branch check workflow to restrict merges from 'staging' to 'development' --- .github/workflows/check-branch.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/check-branch.yml b/.github/workflows/check-branch.yml index 2332f0d..c0b6d08 100644 --- a/.github/workflows/check-branch.yml +++ b/.github/workflows/check-branch.yml @@ -8,13 +8,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Comment PR - if: github.base_ref == 'master' && github.head_ref != 'staging' + if: github.base_ref == 'master' && github.head_ref != 'development' uses: thollander/actions-comment-pull-request@v2 with: message: | - We regret to inform you that you are currently not able to merge your changes into the master branch due to restrictions applied by our SRE team. To proceed with merging your changes, we kindly request that you create a pull request from the staging branch. Our team will then review the changes and work with you to ensure a successful merge into the master branch. + We regret to inform you that you are currently not able to merge your changes into the master branch due to restrictions applied by our SRE team. To proceed with merging your changes, we kindly request that you create a pull request from the development branch. Our team will then review the changes and work with you to ensure a successful merge into the master branch. - name: Check branch - if: github.base_ref == 'master' && github.head_ref != 'staging' + if: github.base_ref == 'master' && github.head_ref != 'development' run: | - echo "ERROR: We regret to inform you that you are currently not able to merge your changes into the master branch due to restrictions applied by our SRE team. To proceed with merging your changes, we kindly request that you create a pull request from the staging branch. Our team will then review the changes and work with you to ensure a successful merge into the master branch." + echo "ERROR: We regret to inform you that you are currently not able to merge your changes into the master branch due to restrictions applied by our SRE team. To proceed with merging your changes, we kindly request that you create a pull request from the development branch. Our team will then review the changes and work with you to ensure a successful merge into the master branch." exit 1