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/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/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/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"> + + + + + + /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") + } +}