From ae4ada07519b4dda90d2ccacd9f7f492da9e1e56 Mon Sep 17 00:00:00 2001 From: Pengfei Wang Date: Thu, 7 May 2026 21:51:28 +0800 Subject: [PATCH 1/2] feat: make sdk more configurable --- .gitignore | 1 + Package.swift | 3 - README.md | 133 +++++++--- .../ControlPlaneTransport.swift | 2 + .../ArchebasePublicEndpoints.swift | 236 ++++++++++++++++++ .../DataGatewayClient/FilePreparation.swift | 174 ++++--------- .../Resources/PublicEndpoints.json | 5 - .../TestHarnessSupport.swift | 57 ++++- .../ArchebaseConfigClientTests.swift | 40 ++- .../FilePreparationTests.swift | 234 ++++++++++++++--- .../LocalStackHarnessTests.swift | 13 +- .../ManualAliyunDeviceInitTests.swift | 76 ++++++ .../UploadCoordinatorTests.swift | 10 +- 13 files changed, 763 insertions(+), 221 deletions(-) create mode 100644 Sources/DataGatewayClient/ArchebasePublicEndpoints.swift delete mode 100644 Sources/DataGatewayClient/Resources/PublicEndpoints.json create mode 100644 Tests/DataGatewayClientIntegrationTests/ManualAliyunDeviceInitTests.swift diff --git a/.gitignore b/.gitignore index e56b869..c2057cf 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ DerivedData/ *.xcuserstate .DS_Store +.tmp diff --git a/Package.swift b/Package.swift index 6cfc5bf..144de15 100644 --- a/Package.swift +++ b/Package.swift @@ -82,9 +82,6 @@ let package = Package( "DGWProto", "DGWStore", .product(name: "GRPCCore", package: "grpc-swift-2"), - ], - resources: [ - .process("Resources/PublicEndpoints.json"), ] ), .testTarget( diff --git a/README.md b/README.md index 1b7a8e5..d8af440 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ SDK 提供以下能力: | macOS 开发环境 | `>= 15` 推荐 | | 并发模型 | Swift Concurrency,主要 API 使用 `async throws` 和 `AsyncThrowingStream` | -SDK 使用资源文件定义 Archebase 公共服务端点,App 不需要也不能在 public API 中传入认证、上传网关或设备初始化端点。 +SDK 使用 App 私有容器中的 `archebase-endpoints.json` 定义 Archebase 公共服务端点。App 需要先通过可信渠道获取 endpoint JSON,并在设备初始化或创建上传客户端前调用 SDK 初始化方法写入本地文件。 ## 3. 接入前需要准备 @@ -36,7 +36,8 @@ SDK 使用资源文件定义 Archebase 公共服务端点,App 不需要也不 1. SDK 包地址或本地源码路径。 2. 首次初始化用的 `deviceID`。 -3. 是否需要在 App 内提供重新初始化入口。 +3. 运行期 endpoint JSON,包含认证、上传网关和设备初始化端点。 +4. 是否需要在 App 内提供重新初始化入口。 `deviceID` 不是用户在 App 中随意生成的 UUID。它应来自接入方的设备管理或交付流程,并由 operator 或管理员录入到 App。 @@ -106,14 +107,16 @@ import Foundation iOS App 推荐使用配置文件驱动方式接入。 -1. App 首次启动或进入设备绑定页时,让 operator 输入平台提供的 `deviceID`。 -2. 调用 `ArchebaseDeviceInitializer.initDevice(deviceID:)`。 -3. SDK 将初始化结果写入 App 私有目录下的 `archebase-config.json`。 -4. App 调用 `DataGatewayClient.fromArchebaseConfig(...)` 创建上传客户端。 -5. 用户选择文件后,App 调用 `uploadEvents(_:)` 或 `upload(_:)` 上传。 -6. App 每次启动后调用 `listPendingUploads()`,为用户展示可恢复任务。 -7. 用户确认恢复时调用 `resumeUpload(logicalUploadID:)`。 -8. 用户放弃上传时调用 `abortUpload(logicalUploadID:)`。 +1. App 计算 App 私有容器内的 `endpointsURL`、`configURL` 和 `persistRootURL`。 +2. App 从可信渠道获取 endpoint JSON,调用 `DataGatewayClient.initialize(endpointsJSON:endpointsURL:)` 写入 `archebase-endpoints.json`。 +3. App 首次启动或进入设备绑定页时,让 operator 输入平台提供的 `deviceID`。 +4. 调用 `ArchebaseDeviceInitializer.initDevice(deviceID:)`。 +5. SDK 将初始化结果写入 App 私有目录下的 `archebase-config.json`。 +6. App 调用 `DataGatewayClient.fromArchebaseConfig(...)` 创建上传客户端。 +7. 用户选择文件后,App 调用 `uploadEvents(_:)` 或 `upload(_:)` 上传。 +8. App 每次启动后调用 `listPendingUploads()`,为用户展示可恢复任务。 +9. 用户确认恢复时调用 `resumeUpload(logicalUploadID:)`。 +10. 用户放弃上传时调用 `abortUpload(logicalUploadID:)`。 如果接入方已经通过其他安全渠道直接向 App 下发 `API Key`,也可以跳过设备初始化,直接使用 `DataGatewayClientConfig.recommended(...)` 创建客户端。生产 App 通常优先使用设备初始化方式。 @@ -129,12 +132,14 @@ let supportRoot = FileManager.default.urls( let archebaseRoot = supportRoot.appendingPathComponent("Archebase", isDirectory: true) +let endpointsURL = archebaseRoot.appendingPathComponent("archebase-endpoints.json") + let configURL = archebaseRoot.appendingPathComponent("archebase-config.json") let persistRootURL = archebaseRoot.appendingPathComponent("Uploads", isDirectory: true) ``` -`archebase-config.json` 包含上传凭证,请不要放入 App bundle、共享容器、剪贴板、日志、埋点或用户可导出的诊断文件中。SDK 在 iOS 上写入该文件时会使用系统文件保护选项。 +`archebase-endpoints.json` 和 `archebase-config.json` 都应放在 App 私有容器内。`archebase-config.json` 包含上传凭证,两个文件都不要放入 App bundle、共享容器、剪贴板、日志、埋点或用户可导出的诊断文件中。SDK 在 iOS 上写入这些文件时会使用系统文件保护选项。 ## 7. 设备初始化 @@ -143,7 +148,8 @@ let persistRootURL = archebaseRoot.appendingPathComponent("Uploads", isDirectory ```swift let initializer = try ArchebaseDeviceInitializer( config: DeviceInitClientConfig( - configURL: configURL + configURL: configURL, + endpointsURL: endpointsURL ) ) @@ -154,11 +160,12 @@ print(deviceConfig.tags) `initDevice(deviceID:)` 的行为: -1. 本地没有 `archebase-config.json` 时,向公共初始化端点请求设备配置并写入本地文件。 -2. 本地已经存在配置文件时,抛出 `DataGatewayClientError.alreadyInitialized(configURL:)`。 -3. 写入成功后返回 `ArchebaseConfig`,其中包含 `API Key` 和设备 tags。 +1. 从 `archebase-endpoints.json` 读取 `deviceInit` endpoint。 +2. 本地没有 `archebase-config.json` 时,向公共初始化端点请求设备配置并写入本地文件。 +3. 本地已经存在配置文件时,抛出 `DataGatewayClientError.alreadyInitialized(configURL:)`。 +4. 写入成功后返回 `ArchebaseConfig`,其中包含 `API Key` 和设备 tags。 -初始化端点来自 `DataGatewayClient` target 的必需资源文件 `PublicEndpoints.json` 中的 `deviceInit` 配置。App 不需要在运行时自行配置初始化端点。 +如果 `archebase-endpoints.json` 不存在,构造 `ArchebaseDeviceInitializer(config:)` 时会抛出 `DataGatewayClientError.endpointsNotInitialized(endpointsURL:)`。App 应先获取 endpoint JSON,调用 `DataGatewayClient.initialize(endpointsJSON:endpointsURL:)`,成功后重试设备初始化。 ### 7.2 重新初始化 @@ -216,6 +223,7 @@ tags 约束: let client = try await DataGatewayClient.fromArchebaseConfig( configURL: configURL, persistRootURL: persistRootURL, + endpointsURL: endpointsURL, observability: .disabled ) ``` @@ -224,31 +232,39 @@ let client = try await DataGatewayClient.fromArchebaseConfig( 如果配置文件不存在,方法会抛出 `DataGatewayClientError.notInitialized(configURL:)`。App 应引导用户先完成设备初始化。 +如果 endpoint 文件不存在,方法会抛出 `DataGatewayClientError.endpointsNotInitialized(endpointsURL:)`。App 应先调用 `DataGatewayClient.initialize(endpointsJSON:endpointsURL:)`,成功后重试创建客户端。 + ### 8.2 使用显式 API Key 创建 仅当接入方已经通过其他安全渠道直接下发 `API Key` 时使用: ```swift -let config = DataGatewayClientConfig.recommended( +let config = try DataGatewayClientConfig.recommended( credentialBase64: "", - persistRootURL: persistRootURL + persistRootURL: persistRootURL, + endpointsURL: endpointsURL ) let client = try DataGatewayClient(config: config) ``` -### 8.3 公共服务端点资源 +### 8.3 运行期公共服务端点 -认证、上传网关和设备初始化端点全部来自 SwiftPM 资源文件: +认证、上传网关和设备初始化端点来自运行期文件 `archebase-endpoints.json`。App 需要通过可信渠道获取 JSON 字符串,并在首次使用 SDK 端点前写入 App 私有容器: -资源文件路径: +```swift +try DataGatewayClient.initialize( + endpointsJSON: endpointsJSONStringFromTrustedChannel, + endpointsURL: endpointsURL +) +``` + +文件名固定为: ```text -Sources/DataGatewayClient/Resources/PublicEndpoints.json +archebase-endpoints.json ``` -`Package.swift` 会精确处理这个文件;如果文件不存在,`swift build` 或 `swift test` 会在构建阶段失败。资源文件存在但格式不合法时,SDK 在解析端点时会失败。 - 示例: ```json @@ -267,7 +283,9 @@ Sources/DataGatewayClient/Resources/PublicEndpoints.json | `host` | DNS hostname、IPv4 或 IPv6,不包含 scheme 和 port | | `port` | `1...65535` 的整数 | -`http` 会使用 plaintext gRPC 连接,`https` 会使用 TLS gRPC 连接。认证、上传网关和设备初始化可以分别指定不同的 `scheme`、`host` 和 `port`。 +顶层必须包含 `auth`、`gateway` 和 `deviceInit`。每组 endpoint 只接受 `scheme`、`host` 和 `port`;旧字段名 `schema` 不再接受。`http` 会使用 plaintext gRPC 连接,`https` 会使用 TLS gRPC 连接。认证、上传网关和设备初始化可以分别指定不同的 `scheme`、`host` 和 `port`。 + +`initialize` 会先完整解析和校验 JSON,成功后才创建目录并原子写入文件。重复传入等价 endpoint 内容会幂等成功;如果目标文件已经存在且内容不同,会抛出 `DataGatewayClientError.endpointsAlreadyInitialized(endpointsURL:)`,避免 App 无意中静默切换服务端。 SwiftPM 命令行构建示例: @@ -289,6 +307,7 @@ App 只负责提供 `deviceID`、本地配置文件路径、上传持久化目 | `authRefreshBefore` | 认证缓存提前刷新时间 | `60s` | | `requestTimeout` | 单次请求超时 | `10s` | | `persistRootURL` | 上传快照和 staging 根目录 | App 私有 `Application Support` 子目录 | +| `endpointsURL` | 运行期公共端点文件 | App 私有 `Application Support/Archebase/archebase-endpoints.json` | | `retryPolicy` | 请求重试策略 | `.recommended` | | `execution` | 上传执行策略 | `.recommended` | | `observability` | 日志和指标回调 | `.disabled` 或宿主 App 自定义 | @@ -525,6 +544,12 @@ do { case .alreadyInitialized: // 本机已经初始化。可以直接创建上传 client。 break + case .endpointsNotInitialized: + // 获取 endpoint JSON,调用 DataGatewayClient.initialize 后重试。 + break + case .endpointsAlreadyInitialized: + // 本机已有不同 endpoint 配置。不要静默覆盖,交给运维流程处理。 + break case .rawTagConflict(let key): // 调整单次上传 rawTags,避免覆盖设备配置 tags。 print("tag conflict: \(key)") @@ -576,6 +601,8 @@ do { |---|---| | `notInitialized` | 展示设备初始化页面。 | | `alreadyInitialized` | 不要重复初始化,直接进入上传流程。 | +| `endpointsNotInitialized` | 获取可信 endpoint JSON,调用 `DataGatewayClient.initialize(endpointsJSON:endpointsURL:)` 后重试。 | +| `endpointsAlreadyInitialized` | 本机已有不同 endpoint 文件,停止自动切换并提示运维处理。 | | `invalidConfiguration` | 检查配置文件、credential 和策略参数是否正确。 | | `rawTagConflict` | 修改单次上传 tags,避免与设备 tags 冲突。 | | `invalidLocalFile` | 让用户重新选择文件。 | @@ -605,6 +632,7 @@ let observability = DataGatewayClientObservability( let client = try await DataGatewayClient.fromArchebaseConfig( configURL: configURL, persistRootURL: persistRootURL, + endpointsURL: endpointsURL, observability: observability ) ``` @@ -637,11 +665,13 @@ SDK 会对包含 `credential`、`token`、`accessKey`、`secret` 等关键词的 App 启动后建议执行: -1. 计算 `configURL` 和 `persistRootURL`。 -2. 尝试调用 `DataGatewayClient.fromArchebaseConfig(...)`。 -3. 如果抛出 `notInitialized`,进入设备初始化流程。 -4. 如果创建成功,调用 `listPendingUploads()`。 -5. 将待恢复任务展示给用户,或按产品策略自动恢复。 +1. 计算 `endpointsURL`、`configURL` 和 `persistRootURL`。 +2. 确保可信 endpoint JSON 已通过 `DataGatewayClient.initialize(endpointsJSON:endpointsURL:)` 写入。 +3. 尝试调用 `DataGatewayClient.fromArchebaseConfig(...)`。 +4. 如果抛出 `notInitialized`,进入设备初始化流程。 +5. 如果抛出 `endpointsNotInitialized`,获取 endpoint JSON 并初始化后重试。 +6. 如果创建成功,调用 `listPendingUploads()`。 +7. 将待恢复任务展示给用户,或按产品策略自动恢复。 示例: @@ -650,7 +680,8 @@ func makeClientOrRequireInitialization() async throws -> DataGatewayClient? { do { return try await DataGatewayClient.fromArchebaseConfig( configURL: configURL, - persistRootURL: persistRootURL + persistRootURL: persistRootURL, + endpointsURL: endpointsURL ) } catch let error as DataGatewayClientError { if case .notInitialized = error { @@ -691,6 +722,7 @@ import DGWStore import Foundation actor GatewayUploadService { + private let endpointsURL: URL private let configURL: URL private let persistRootURL: URL private var client: DataGatewayClient? @@ -702,13 +734,24 @@ actor GatewayUploadService { )[0] let archebaseRoot = supportRoot.appendingPathComponent("Archebase", isDirectory: true) + self.endpointsURL = archebaseRoot.appendingPathComponent("archebase-endpoints.json") self.configURL = archebaseRoot.appendingPathComponent("archebase-config.json") self.persistRootURL = archebaseRoot.appendingPathComponent("Uploads", isDirectory: true) } + func initializeEndpoints(endpointsJSON: String) throws { + try DataGatewayClient.initialize( + endpointsJSON: endpointsJSON, + endpointsURL: self.endpointsURL + ) + } + func initializeDevice(deviceID: String) async throws { let initializer = try ArchebaseDeviceInitializer( - config: DeviceInitClientConfig(configURL: self.configURL) + config: DeviceInitClientConfig( + configURL: self.configURL, + endpointsURL: self.endpointsURL + ) ) _ = try await initializer.initDevice(deviceID: deviceID) self.client = try await self.makeClient() @@ -752,7 +795,8 @@ actor GatewayUploadService { private func makeClient() async throws -> DataGatewayClient { try await DataGatewayClient.fromArchebaseConfig( configURL: self.configURL, - persistRootURL: self.persistRootURL + persistRootURL: self.persistRootURL, + endpointsURL: self.endpointsURL ) } } @@ -768,11 +812,14 @@ actor GatewayUploadService { ```swift public actor DataGatewayClient { + public static func initialize(endpointsJSON: String, endpointsURL: URL) throws + public init(config: DataGatewayClientConfig) throws public static func fromArchebaseConfig( configURL: URL, persistRootURL: URL, + endpointsURL: URL, observability: DataGatewayClientObservability = .disabled ) async throws -> DataGatewayClient @@ -795,7 +842,14 @@ public actor DataGatewayClient { ```swift public struct DeviceInitClientConfig: Sendable { public var configURL: URL + public var endpointsURL: URL public var requestTimeout: Duration + + public init( + configURL: URL, + endpointsURL: URL, + requestTimeout: Duration = .seconds(10) + ) } public actor ArchebaseDeviceInitializer { @@ -874,8 +928,9 @@ public struct DataGatewayClientConfig: Sendable { public static func recommended( credentialBase64: String, persistRootURL: URL, + endpointsURL: URL, observability: DataGatewayClientObservability = .disabled - ) -> DataGatewayClientConfig + ) throws -> DataGatewayClientConfig public func validate() throws } @@ -940,6 +995,8 @@ public enum DataGatewayClientError: Error, Sendable, Equatable { case invalidConfiguration(String) case alreadyInitialized(configURL: URL) case notInitialized(configURL: URL) + case endpointsAlreadyInitialized(endpointsURL: URL) + case endpointsNotInitialized(endpointsURL: URL) case invalidLocalFile(String) case zeroByteFile case ossFailed(httpStatus: Int?, ossCode: String?, message: String) @@ -955,8 +1012,8 @@ public enum DataGatewayClientError: Error, Sendable, Equatable { ## 19. 上线前检查清单 -1. 确认 `Sources/DataGatewayClient/Resources/PublicEndpoints.json` 中的认证、上传网关和设备初始化端点正确,App 网络环境可以访问这些端点。 -2. `archebase-config.json` 写入 App 私有目录,不进入日志、备份导出或共享容器。 +1. 确认 `archebase-endpoints.json` 已初始化到 App 私有目录,包含 `auth`、`gateway` 和 `deviceInit` 三组 endpoint,App 网络环境可以访问这些端点。 +2. `archebase-endpoints.json` 和 `archebase-config.json` 写入 App 私有目录,不进入日志、备份导出或共享容器。 3. App 支持首次初始化、已初始化跳过、重新初始化和初始化失败提示。 4. 上传 UI 支持进度、成功、失败、重试、恢复和取消。 5. App 启动后会调用 `listPendingUploads()` 并处理待恢复任务。 @@ -971,7 +1028,9 @@ public enum DataGatewayClientError: Error, Sendable, Equatable { | 现象 | 优先检查 | |---|---| | 创建 client 时报 `notInitialized` | 是否已成功调用 `initDevice`,`configURL` 是否一致。 | -| 创建 client 时报 `invalidConfiguration` | 配置文件 JSON、credential 和本地持久化路径是否有效。 | +| 创建 client 时报 `endpointsNotInitialized` | 是否已获取可信 endpoint JSON 并调用 `DataGatewayClient.initialize(endpointsJSON:endpointsURL:)`,`endpointsURL` 是否一致。 | +| 初始化 endpoint 时报 `endpointsAlreadyInitialized` | 本机是否已有不同 endpoint 文件;不要自动覆盖,按运维流程确认是否需要清理或迁移。 | +| 创建 client 时报 `invalidConfiguration` | 配置文件 JSON、endpoint JSON、credential 和本地持久化路径是否有效。 | | 上传立即失败 `zeroByteFile` | 用户选择的文件是否为空。 | | 上传立即失败 `invalidLocalFile` | 文件是否仍存在,App 是否有访问权限。 | | 上传失败 `rawTagConflict` | `UploadRequest.rawTags` 是否覆盖了设备配置 tags 的同名 key。 | diff --git a/Sources/DGWControlPlane/ControlPlaneTransport.swift b/Sources/DGWControlPlane/ControlPlaneTransport.swift index 13e26e8..b88dc29 100644 --- a/Sources/DGWControlPlane/ControlPlaneTransport.swift +++ b/Sources/DGWControlPlane/ControlPlaneTransport.swift @@ -409,6 +409,8 @@ public enum DataGatewayClientError: Error, Sendable, Equatable { case invalidConfiguration(String) case alreadyInitialized(configURL: URL) case notInitialized(configURL: URL) + case endpointsAlreadyInitialized(endpointsURL: URL) + case endpointsNotInitialized(endpointsURL: URL) case invalidLocalFile(String) case zeroByteFile case ossFailed(httpStatus: Int?, ossCode: String?, message: String) diff --git a/Sources/DataGatewayClient/ArchebasePublicEndpoints.swift b/Sources/DataGatewayClient/ArchebasePublicEndpoints.swift new file mode 100644 index 0000000..7e221e3 --- /dev/null +++ b/Sources/DataGatewayClient/ArchebasePublicEndpoints.swift @@ -0,0 +1,236 @@ +import DGWControlPlane +import Foundation + +/// Runtime store for Archebase public service endpoints. +public enum ArchebasePublicEndpoints { + package struct Resolved: Sendable, Equatable { + package var auth: URL + package var gateway: URL + package var deviceInit: URL + package var authTLS: TLSMode + package var gatewayTLS: TLSMode + package var deviceInitTLS: TLSMode + } + + public static let endpointsFileName = "archebase-endpoints.json" + + public static func defaultEndpointsURL() throws -> URL { + guard let applicationSupport = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first else { + throw DataGatewayClientError.invalidConfiguration("application support directory is unavailable") + } + + return applicationSupport + .appendingPathComponent("Archebase", isDirectory: true) + .appendingPathComponent(Self.endpointsFileName, isDirectory: false) + .standardizedFileURL + } + + package static func decodeEndpoints(_ data: Data) throws -> Resolved { + do { + let payload = try JSONDecoder().decode(EndpointsPayload.self, from: data) + let auth = try payload.auth.resolvedURL(fieldName: "auth") + let gateway = try payload.gateway.resolvedURL(fieldName: "gateway") + let deviceInit = try payload.deviceInit.resolvedURL(fieldName: "deviceInit") + return Resolved( + auth: auth.url, + gateway: gateway.url, + deviceInit: deviceInit.url, + authTLS: auth.tls, + gatewayTLS: gateway.tls, + deviceInitTLS: deviceInit.tls + ) + } catch let error as DataGatewayClientError { + throw error + } catch { + throw DataGatewayClientError.invalidConfiguration( + "failed to decode archebase endpoints: \(error.localizedDescription)" + ) + } + } + + package static func load(endpointsURL: URL) throws -> Resolved { + let resolvedURL = endpointsURL.standardizedFileURL + guard FileManager.default.fileExists(atPath: resolvedURL.path()) else { + throw DataGatewayClientError.endpointsNotInitialized(endpointsURL: resolvedURL) + } + + do { + let data = try Data(contentsOf: resolvedURL) + return try Self.decodeEndpoints(data) + } catch let error as DataGatewayClientError { + throw error + } catch { + throw DataGatewayClientError.invalidConfiguration( + "failed to load archebase endpoints: \(error.localizedDescription)" + ) + } + } + + public static func initialize(endpointsJSON: String, endpointsURL: URL) throws { + guard !endpointsJSON.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw DataGatewayClientError.invalidConfiguration("archebase endpoints json must not be empty") + } + + let data = Data(endpointsJSON.utf8) + let expected = try Self.decodeEndpoints(data) + let resolvedURL = endpointsURL.standardizedFileURL + let fileManager = FileManager.default + + if fileManager.fileExists(atPath: resolvedURL.path()) { + let existing = try Self.load(endpointsURL: resolvedURL) + guard existing == expected else { + throw DataGatewayClientError.endpointsAlreadyInitialized(endpointsURL: resolvedURL) + } + return + } + + try Self.atomicWrite(data, expected: expected, to: resolvedURL, fileManager: fileManager) + } + + private static func atomicWrite( + _ data: Data, + expected: Resolved, + to endpointsURL: URL, + fileManager: FileManager + ) throws { + let parent = endpointsURL.deletingLastPathComponent() + let tempURL = parent.appendingPathComponent(".\(endpointsURL.lastPathComponent).\(UUID().uuidString).tmp") + + do { + try fileManager.createDirectory(at: parent, withIntermediateDirectories: true) + try Self.writeProtected(data, to: tempURL) + do { + try fileManager.moveItem(at: tempURL, to: endpointsURL) + } catch { + if fileManager.fileExists(atPath: endpointsURL.path()) { + let existing = try Self.load(endpointsURL: endpointsURL) + guard existing == expected else { + throw DataGatewayClientError.endpointsAlreadyInitialized(endpointsURL: endpointsURL) + } + return + } + throw error + } + + let loaded = try Self.load(endpointsURL: endpointsURL) + guard loaded == expected else { + throw DataGatewayClientError.persistenceFailed("archebase endpoints verification failed after write") + } + } catch let error as DataGatewayClientError { + try? fileManager.removeItem(at: tempURL) + throw error + } catch { + try? fileManager.removeItem(at: tempURL) + throw DataGatewayClientError.persistenceFailed( + "failed to write archebase endpoints: \(error.localizedDescription)" + ) + } + } + + private static func writeProtected(_ data: Data, to url: URL) throws { + #if os(iOS) + try data.write(to: url, options: [.completeFileProtectionUnlessOpen]) + #else + try data.write(to: url, options: []) + #endif + } +} + +private struct EndpointsPayload: Decodable { + var auth: EndpointPayload + var gateway: EndpointPayload + var deviceInit: EndpointPayload +} + +private struct EndpointPayload: Decodable { + private static let allowedFields: Set = ["scheme", "host", "port"] + + var scheme: String? + var host: String? + var port: Int? + var unsupportedFields: [String] + var hasLegacySchemaField: Bool + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKey.self) + let keys = container.allKeys.map(\.stringValue) + self.unsupportedFields = keys.filter { !Self.allowedFields.contains($0) }.sorted() + self.hasLegacySchemaField = keys.contains("schema") + self.scheme = try container.decodeIfPresent(String.self, forKey: DynamicCodingKey("scheme")) + self.host = try container.decodeIfPresent(String.self, forKey: DynamicCodingKey("host")) + self.port = try container.decodeIfPresent(Int.self, forKey: DynamicCodingKey("port")) + } + + func resolvedURL(fieldName: String) throws -> (url: URL, tls: TLSMode) { + if self.hasLegacySchemaField { + throw DataGatewayClientError.invalidConfiguration("\(fieldName).schema is not supported; use scheme") + } + + if let unsupportedField = self.unsupportedFields.first { + throw DataGatewayClientError.invalidConfiguration( + "\(fieldName) contains unsupported field '\(unsupportedField)'" + ) + } + + guard let scheme else { + throw DataGatewayClientError.invalidConfiguration("\(fieldName).scheme is required") + } + let normalizedScheme = scheme.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let tls: TLSMode + switch normalizedScheme { + case "http": + tls = .plaintext + case "https": + tls = .tls + default: + throw DataGatewayClientError.invalidConfiguration("\(fieldName).scheme must be http or https") + } + + guard let host else { + throw DataGatewayClientError.invalidConfiguration("\(fieldName).host is required") + } + let normalizedHost = host.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedHost.isEmpty else { + throw DataGatewayClientError.invalidConfiguration("\(fieldName).host must not be empty") + } + + guard let port else { + throw DataGatewayClientError.invalidConfiguration("\(fieldName).port is required") + } + guard (1 ... 65535).contains(port) else { + throw DataGatewayClientError.invalidConfiguration("\(fieldName).port must be between 1 and 65535") + } + + var components = URLComponents() + components.scheme = normalizedScheme + components.host = normalizedHost + components.port = port + guard let url = components.url else { + throw DataGatewayClientError.invalidConfiguration("\(fieldName) endpoint is not a valid URL") + } + + return (url, tls) + } +} + +private struct DynamicCodingKey: CodingKey { + var stringValue: String + var intValue: Int? + + init(_ stringValue: String) { + self.stringValue = stringValue + self.intValue = nil + } + + init?(stringValue: String) { + self.init(stringValue) + } + + init?(intValue: Int) { + self.stringValue = "\(intValue)" + self.intValue = intValue + } +} diff --git a/Sources/DataGatewayClient/FilePreparation.swift b/Sources/DataGatewayClient/FilePreparation.swift index 8e755a0..c410b28 100644 --- a/Sources/DataGatewayClient/FilePreparation.swift +++ b/Sources/DataGatewayClient/FilePreparation.swift @@ -16,133 +16,23 @@ public enum DataGatewayClientModule { public static let version = "0.1.0" } -/// Archebase public service endpoints loaded from the required SDK resource. -public enum ArchebasePublicEndpoints { - package struct Resolved: Sendable, Equatable { - package var auth: URL - package var gateway: URL - package var deviceInit: URL - package var authTLS: TLSMode - package var gatewayTLS: TLSMode - package var deviceInitTLS: TLSMode - } - - /// Public authentication service endpoint. - public static var auth: URL { resolved.auth } - - /// Public data gateway control-plane service endpoint. - public static var gateway: URL { resolved.gateway } - - /// Public device initialization service endpoint. - public static var deviceInit: URL { resolved.deviceInit } - - package static var authTLS: TLSMode { resolved.authTLS } - package static var gatewayTLS: TLSMode { resolved.gatewayTLS } - package static var deviceInitTLS: TLSMode { resolved.deviceInitTLS } - - package static let resourceName = "PublicEndpoints" - - private static let resolved = loadResource() - - private static func loadResource() -> Resolved { - guard let url = Bundle.module.url(forResource: resourceName, withExtension: "json") else { - fatalError("missing \(resourceName).json resource") - } - do { - let data = try Data(contentsOf: url) - return try decodeResource(data) - } catch { - fatalError("invalid \(resourceName).json: \(error)") - } - } - - package static func decodeResource(_ data: Data) throws -> Resolved { - let payload = try JSONDecoder().decode(ResourcePayload.self, from: data) - let auth = try payload.auth.resolvedURL(fieldName: "auth") - let gateway = try payload.gateway.resolvedURL(fieldName: "gateway") - let deviceInit = try payload.deviceInit.resolvedURL(fieldName: "deviceInit") - return Resolved( - auth: auth.url, - gateway: gateway.url, - deviceInit: deviceInit.url, - authTLS: auth.tls, - gatewayTLS: gateway.tls, - deviceInitTLS: deviceInit.tls - ) - } - - private struct ResourcePayload: Decodable { - var auth: ResourceEndpoint - var gateway: ResourceEndpoint - var deviceInit: ResourceEndpoint - } - - private struct ResourceEndpoint: Decodable { - var scheme: String - var host: String - var port: Int - - enum CodingKeys: String, CodingKey { - case scheme - case schema - case host - case port - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.scheme = try container.decodeIfPresent(String.self, forKey: .scheme) - ?? container.decode(String.self, forKey: .schema) - self.host = try container.decode(String.self, forKey: .host) - self.port = try container.decode(Int.self, forKey: .port) - } - - func resolvedURL(fieldName: String) throws -> (url: URL, tls: TLSMode) { - let normalizedScheme = self.scheme.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - let tls: TLSMode - switch normalizedScheme { - case "http": - tls = .plaintext - case "https": - tls = .tls - default: - throw DataGatewayClientError.invalidConfiguration("\(fieldName).scheme must be http or https") - } - - let normalizedHost = self.host.trimmingCharacters(in: .whitespacesAndNewlines) - guard !normalizedHost.isEmpty else { - throw DataGatewayClientError.invalidConfiguration("\(fieldName).host must not be empty") - } - guard (1 ... 65535).contains(self.port) else { - throw DataGatewayClientError.invalidConfiguration("\(fieldName).port must be between 1 and 65535") - } - - var components = URLComponents() - components.scheme = normalizedScheme - components.host = normalizedHost - components.port = self.port - guard let url = components.url else { - throw DataGatewayClientError.invalidConfiguration("\(fieldName) endpoint is not a valid URL") - } - return (url, tls) - } - } -} - /// Public configuration for device initialization and reinitialization. public struct DeviceInitClientConfig: Sendable { public var configURL: URL + public var endpointsURL: URL public var requestTimeout: Duration - package var tls: TLSMode + package var tls: TLSMode? - /// Creates a device initialization configuration that uses the resource-defined public endpoint. + /// Creates a device initialization configuration that loads the runtime public endpoint. public init( configURL: URL, + endpointsURL: URL, requestTimeout: Duration = .seconds(10) ) { self.configURL = configURL + self.endpointsURL = endpointsURL self.requestTimeout = requestTimeout - self.tls = ArchebasePublicEndpoints.deviceInitTLS + self.tls = nil } package init( @@ -151,6 +41,10 @@ public struct DeviceInitClientConfig: Sendable { tls: TLSMode ) { self.configURL = configURL + self.endpointsURL = configURL + .deletingLastPathComponent() + .appendingPathComponent(ArchebasePublicEndpoints.endpointsFileName) + .standardizedFileURL self.requestTimeout = requestTimeout self.tls = tls } @@ -365,7 +259,7 @@ public struct DataGatewayClientConfig: Sendable { package var tls: TLSMode { self.authTLS } public var observability: DataGatewayClientObservability - /// Creates a client configuration that uses the resource-defined public endpoints. + /// Creates a client configuration that uses the runtime public endpoints file. public init( credentialBase64: String, authRefreshBefore: Duration, @@ -373,18 +267,20 @@ public struct DataGatewayClientConfig: Sendable { persistRootURL: URL, retryPolicy: RetryPolicySet, execution: UploadExecutionPolicy, + endpointsURL: URL, observability: DataGatewayClientObservability = .disabled - ) { - self.authEndpoint = ArchebasePublicEndpoints.auth - self.gatewayEndpoint = ArchebasePublicEndpoints.gateway + ) throws { + let endpoints = try ArchebasePublicEndpoints.load(endpointsURL: endpointsURL) + self.authEndpoint = endpoints.auth + self.gatewayEndpoint = endpoints.gateway self.credentialBase64 = credentialBase64 self.authRefreshBefore = authRefreshBefore self.requestTimeout = requestTimeout self.persistRootURL = persistRootURL self.retryPolicy = retryPolicy self.execution = execution - self.authTLS = ArchebasePublicEndpoints.authTLS - self.gatewayTLS = ArchebasePublicEndpoints.gatewayTLS + self.authTLS = endpoints.authTLS + self.gatewayTLS = endpoints.gatewayTLS self.observability = observability } @@ -413,19 +309,21 @@ public struct DataGatewayClientConfig: Sendable { self.observability = observability } - /// Recommended defaults for resource-defined public endpoints. + /// Recommended defaults for runtime public endpoints. public static func recommended( credentialBase64: String, persistRootURL: URL, + endpointsURL: URL, observability: DataGatewayClientObservability = .disabled - ) -> DataGatewayClientConfig { - DataGatewayClientConfig( + ) throws -> DataGatewayClientConfig { + try DataGatewayClientConfig( credentialBase64: credentialBase64, authRefreshBefore: .seconds(60), requestTimeout: .seconds(10), persistRootURL: persistRootURL, retryPolicy: .recommended, execution: .recommended, + endpointsURL: endpointsURL, observability: observability ) } @@ -532,11 +430,14 @@ public actor ArchebaseDeviceInitializer { private let sdkVersion: String private let platform: String - /// Creates an initializer that targets the resource-defined public initialization endpoint. + /// Creates an initializer that targets the runtime public initialization endpoint. public init(config: DeviceInitClientConfig) throws { + let endpoints = try ArchebasePublicEndpoints.load(endpointsURL: config.endpointsURL) + var resolvedConfig = config + resolvedConfig.tls = config.tls ?? endpoints.deviceInitTLS try self.init( - config: config, - initEndpoint: ArchebasePublicEndpoints.deviceInit, + config: resolvedConfig, + initEndpoint: endpoints.deviceInit, sdkVersion: DataGatewayClientModule.version, platform: "ios" ) @@ -548,11 +449,12 @@ public actor ArchebaseDeviceInitializer { sdkVersion: String = DataGatewayClientModule.version, platform: String = "ios" ) throws { - let security: ControlPlaneTransportSecurity = switch config.tls { + let tls = config.tls ?? Self.tlsMode(for: initEndpoint) + let security: ControlPlaneTransportSecurity = switch tls { case .plaintext: .plaintext case .tls: .tls } - try DataGatewayClientConfig.validate(endpoint: initEndpoint, tls: config.tls, fieldName: "initEndpoint") + try DataGatewayClientConfig.validate(endpoint: initEndpoint, tls: tls, fieldName: "initEndpoint") let factory = ControlPlaneClientFactory( configuration: ControlPlaneTransportConfiguration( @@ -571,6 +473,10 @@ public actor ArchebaseDeviceInitializer { ) } + private static func tlsMode(for endpoint: URL) -> TLSMode { + endpoint.scheme?.lowercased() == "https" ? .tls : .plaintext + } + package init( configStore: ArchebaseConfigStore, initTransport: any DeviceInitTransport, @@ -1693,6 +1599,10 @@ public actor DataGatewayClient { private let runtimeResources: DataGatewayClientRuntimeResources? private let configTags: [String: String] + public static func initialize(endpointsJSON: String, endpointsURL: URL) throws { + try ArchebasePublicEndpoints.initialize(endpointsJSON: endpointsJSON, endpointsURL: endpointsURL) + } + /// Creates a fully wired client from the public configuration. public init(config: DataGatewayClientConfig) throws { try self.init(config: config, configTags: [:]) @@ -1795,13 +1705,15 @@ public actor DataGatewayClient { public static func fromArchebaseConfig( configURL: URL, persistRootURL: URL, + endpointsURL: URL, observability: DataGatewayClientObservability = .disabled ) async throws -> DataGatewayClient { let store = ArchebaseConfigStore(configURL: configURL) let archebaseConfig = try await store.load() - let config = DataGatewayClientConfig.recommended( + let config = try DataGatewayClientConfig.recommended( credentialBase64: archebaseConfig.apiKey, persistRootURL: persistRootURL, + endpointsURL: endpointsURL, observability: observability ) return try DataGatewayClient(config: config, configTags: archebaseConfig.tags) diff --git a/Sources/DataGatewayClient/Resources/PublicEndpoints.json b/Sources/DataGatewayClient/Resources/PublicEndpoints.json deleted file mode 100644 index ce18973..0000000 --- a/Sources/DataGatewayClient/Resources/PublicEndpoints.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "auth": { "scheme": "http", "host": "nlb-isnnehtfmxqv70lvm9.cn-shanghai.nlb.aliyuncsslb.com", "port": 50051 }, - "gateway": { "scheme": "http", "host": "nlb-isnnehtfmxqv70lvm9.cn-shanghai.nlb.aliyuncsslb.com", "port": 50053 }, - "deviceInit": { "scheme": "http", "host": "nlb-isnnehtfmxqv70lvm9.cn-shanghai.nlb.aliyuncsslb.com", "port": 50057 } -} diff --git a/Sources/DataGatewayClient/TestHarnessSupport.swift b/Sources/DataGatewayClient/TestHarnessSupport.swift index 2d09aad..ba96b64 100644 --- a/Sources/DataGatewayClient/TestHarnessSupport.swift +++ b/Sources/DataGatewayClient/TestHarnessSupport.swift @@ -222,9 +222,15 @@ package struct AliyunOSSTestEnvironment: Sendable { ) if self.environment["DGW_PUBLIC_DNS_INTEGRATION"] == "1" { - return DataGatewayClientConfig.recommended( + let endpointsURL = persistRoot.appendingPathComponent(ArchebasePublicEndpoints.endpointsFileName) + try DataGatewayClient.initialize( + endpointsJSON: try self.publicEndpointsJSON(), + endpointsURL: endpointsURL + ) + return try DataGatewayClientConfig.recommended( credentialBase64: credentialBase64, - persistRootURL: persistRoot + persistRootURL: persistRoot, + endpointsURL: endpointsURL ) } @@ -269,6 +275,53 @@ package struct AliyunOSSTestEnvironment: Sendable { throw LocalStackHarnessError.invalidEndpoint(key) } + private func optionalURL(for key: String) throws -> URL? { + guard let value = self.environment[key]?.trimmedNonEmpty else { + return nil + } + guard let url = Self.normalizedURL(from: value) else { + throw LocalStackHarnessError.invalidEndpoint(key) + } + return url + } + + private func publicEndpointsJSON() throws -> String { + if let json = self.environment["DGW_PUBLIC_ENDPOINTS_JSON"]?.trimmedNonEmpty { + return json + } + if let path = self.environment["DGW_PUBLIC_ENDPOINTS_FILE"]?.trimmedNonEmpty { + return try String(contentsOf: URL(fileURLWithPath: path), encoding: .utf8) + } + + let authEndpoint = try self.requiredURL(for: "DGW_REAL_AUTH_ENDPOINT") + let gatewayEndpoint = try self.requiredURL(for: "DGW_REAL_GATEWAY_ENDPOINT") + let deviceInitEndpoint = try self.optionalURL(for: "DGW_REAL_INIT_ENDPOINT") + ?? self.optionalURL(for: "DGW_REAL_DEVICE_INIT_ENDPOINT") + ?? gatewayEndpoint + return Self.endpointsJSON( + authEndpoint: authEndpoint, + gatewayEndpoint: gatewayEndpoint, + deviceInitEndpoint: deviceInitEndpoint + ) + } + + private static func endpointsJSON(authEndpoint: URL, gatewayEndpoint: URL, deviceInitEndpoint: URL) -> String { + """ + { + "auth": \(Self.endpointJSON(authEndpoint)), + "gateway": \(Self.endpointJSON(gatewayEndpoint)), + "deviceInit": \(Self.endpointJSON(deviceInitEndpoint)) + } + """ + } + + private static func endpointJSON(_ endpoint: URL) -> String { + let scheme = endpoint.scheme?.lowercased() ?? "https" + let host = endpoint.host(percentEncoded: false) ?? endpoint.host ?? "" + let port = endpoint.port ?? (scheme == "https" ? 443 : 80) + return #"{"scheme":"\#(scheme)","host":"\#(host)","port":\#(port)}"# + } + private static func normalizedURL(from value: String) -> URL? { if let url = URL(string: value), url.host?.isEmpty == false { return url diff --git a/Tests/DataGatewayClientIntegrationTests/ArchebaseConfigClientTests.swift b/Tests/DataGatewayClientIntegrationTests/ArchebaseConfigClientTests.swift index ace9102..460ccf3 100644 --- a/Tests/DataGatewayClientIntegrationTests/ArchebaseConfigClientTests.swift +++ b/Tests/DataGatewayClientIntegrationTests/ArchebaseConfigClientTests.swift @@ -10,24 +10,50 @@ import Testing @Test func fromArchebaseConfigRejectsMissingConfig() async throws { let root = try temporaryRoot() let configURL = root.appendingPathComponent("archebase-config.json") + let endpointsURL = root.appendingPathComponent(ArchebasePublicEndpoints.endpointsFileName) let error = await #expect(throws: DataGatewayClientError.self) { _ = try await DataGatewayClient.fromArchebaseConfig( configURL: configURL, - persistRootURL: root + persistRootURL: root, + endpointsURL: endpointsURL ) } #expect(error == .notInitialized(configURL: configURL.standardizedFileURL)) } +@Test func fromArchebaseConfigThrowsMissingEndpointsAfterConfigExists() async throws { + let root = try temporaryRoot() + let configURL = root.appendingPathComponent("archebase-config.json") + let endpointsURL = root.appendingPathComponent(ArchebasePublicEndpoints.endpointsFileName) + let config = try ArchebaseConfig(apiKey: "credential-base64", tags: ["device": "robot"]) + try await ArchebaseConfigStore(configURL: configURL).initialize(config) + + let error = await #expect(throws: DataGatewayClientError.self) { + _ = try await DataGatewayClient.fromArchebaseConfig( + configURL: configURL, + persistRootURL: root, + endpointsURL: endpointsURL + ) + } + + #expect(error == .endpointsNotInitialized(endpointsURL: endpointsURL.standardizedFileURL)) +} + @Test func fromArchebaseConfigBuildsPublicEndpointClient() async throws { let root = try temporaryRoot() let configURL = root.appendingPathComponent("archebase-config.json") + let endpointsURL = root.appendingPathComponent(ArchebasePublicEndpoints.endpointsFileName) let config = try ArchebaseConfig(apiKey: "credential-base64", tags: ["device": "robot"]) try await ArchebaseConfigStore(configURL: configURL).initialize(config) + try DataGatewayClient.initialize(endpointsJSON: configClientEndpointsJSON(), endpointsURL: endpointsURL) - _ = try await DataGatewayClient.fromArchebaseConfig(configURL: configURL, persistRootURL: root) + _ = try await DataGatewayClient.fromArchebaseConfig( + configURL: configURL, + persistRootURL: root, + endpointsURL: endpointsURL + ) } @Test func rawTagsMergerMergesConfigAndUploadTags() throws { @@ -129,6 +155,16 @@ private func temporaryRoot() throws -> URL { return root } +private func configClientEndpointsJSON() -> String { + """ + { + "auth": { "scheme": "http", "host": "auth.example.com", "port": 50051 }, + "gateway": { "scheme": "http", "host": "gateway.example.com", "port": 50053 }, + "deviceInit": { "scheme": "https", "host": "init.example.com", "port": 443 } + } + """ +} + private func makeTestExecutionPolicy() -> UploadExecutionPolicy { UploadExecutionPolicy( maxRestartCount: 1, diff --git a/Tests/DataGatewayClientIntegrationTests/FilePreparationTests.swift b/Tests/DataGatewayClientIntegrationTests/FilePreparationTests.swift index 2332957..da4552e 100644 --- a/Tests/DataGatewayClientIntegrationTests/FilePreparationTests.swift +++ b/Tests/DataGatewayClientIntegrationTests/FilePreparationTests.swift @@ -96,76 +96,242 @@ func makePersistencePolicy(copyExternalFileIntoManagedStaging: Bool) -> LocalPer #expect(DataGatewayClientModule.name == "DataGatewayClient") } -@Test func publicEndpointsLoadRequiredResourceContract() { - for endpoint in [ArchebasePublicEndpoints.auth, ArchebasePublicEndpoints.gateway, ArchebasePublicEndpoints.deviceInit] { - #expect(endpoint.scheme == "http" || endpoint.scheme == "https") - #expect(endpoint.host?.isEmpty == false) - #expect(endpoint.port != nil) - } +@Test func endpointDecodeAcceptsCurrentContract() throws { + let endpoints = try ArchebasePublicEndpoints.decodeEndpoints(validEndpointsJSON().data(using: .utf8)!) + + #expect(endpoints.auth == URL(string: "http://auth.example.com:50051")!) + #expect(endpoints.gateway == URL(string: "http://gateway.example.com:50053")!) + #expect(endpoints.deviceInit == URL(string: "https://init.example.com:443")!) + #expect(endpoints.authTLS == .plaintext) + #expect(endpoints.gatewayTLS == .plaintext) + #expect(endpoints.deviceInitTLS == .tls) } -@Test func publicEndpointResourceParsesHttpAndHttpsValues() throws { - let payload = Data(""" +@Test func endpointDecodeRejectsLegacySchemaField() { + let legacySchema = Data(""" { - "auth": { "schema": "http", "host": "nlb.example.com", "port": 50051 }, - "gateway": { "scheme": "http", "host": "nlb.example.com", "port": 50053 }, + "auth": { "schema": "http", "host": "auth.example.com", "port": 50051 }, + "gateway": { "scheme": "http", "host": "gateway.example.com", "port": 50053 }, "deviceInit": { "scheme": "https", "host": "init.example.com", "port": 443 } } """.utf8) - let endpoints = try ArchebasePublicEndpoints.decodeResource(payload) - - #expect(endpoints.auth == URL(string: "http://nlb.example.com:50051")!) - #expect(endpoints.gateway == URL(string: "http://nlb.example.com:50053")!) - #expect(endpoints.deviceInit == URL(string: "https://init.example.com:443")!) - #expect(endpoints.authTLS == .plaintext) - #expect(endpoints.gatewayTLS == .plaintext) - #expect(endpoints.deviceInitTLS == .tls) + #expect(throws: DataGatewayClientError.self) { + try ArchebasePublicEndpoints.decodeEndpoints(legacySchema) + } } -@Test func publicEndpointResourceRejectsInvalidSchemeAndPort() { +@Test func endpointDecodeRejectsInvalidScheme() { let invalidScheme = Data(""" { "auth": { "scheme": "grpc", "host": "auth.example.com", "port": 50051 }, "gateway": { "scheme": "http", "host": "gateway.example.com", "port": 50053 }, - "deviceInit": { "scheme": "http", "host": "init.example.com", "port": 50057 } + "deviceInit": { "scheme": "https", "host": "init.example.com", "port": 443 } + } + """.utf8) + + #expect(throws: DataGatewayClientError.self) { + try ArchebasePublicEndpoints.decodeEndpoints(invalidScheme) + } +} + +@Test func endpointDecodeRejectsEmptyHost() { + let emptyHost = Data(""" + { + "auth": { "scheme": "http", "host": " ", "port": 50051 }, + "gateway": { "scheme": "http", "host": "gateway.example.com", "port": 50053 }, + "deviceInit": { "scheme": "https", "host": "init.example.com", "port": 443 } } """.utf8) + + #expect(throws: DataGatewayClientError.self) { + try ArchebasePublicEndpoints.decodeEndpoints(emptyHost) + } +} + +@Test func endpointDecodeRejectsPortBelowRange() { let invalidPort = Data(""" { "auth": { "scheme": "http", "host": "auth.example.com", "port": 0 }, "gateway": { "scheme": "http", "host": "gateway.example.com", "port": 50053 }, - "deviceInit": { "scheme": "http", "host": "init.example.com", "port": 50057 } + "deviceInit": { "scheme": "https", "host": "init.example.com", "port": 443 } } """.utf8) #expect(throws: DataGatewayClientError.self) { - try ArchebasePublicEndpoints.decodeResource(invalidScheme) + try ArchebasePublicEndpoints.decodeEndpoints(invalidPort) + } +} + +@Test func endpointDecodeRejectsPortAboveRange() { + let invalidPort = Data(""" + { + "auth": { "scheme": "http", "host": "auth.example.com", "port": 65536 }, + "gateway": { "scheme": "http", "host": "gateway.example.com", "port": 50053 }, + "deviceInit": { "scheme": "https", "host": "init.example.com", "port": 443 } + } + """.utf8) + + #expect(throws: DataGatewayClientError.self) { + try ArchebasePublicEndpoints.decodeEndpoints(invalidPort) + } +} + +@Test func endpointLoadRejectsMissingFile() throws { + let root = try filePreparationTemporaryRoot() + let endpointsURL = root.appendingPathComponent(ArchebasePublicEndpoints.endpointsFileName) + + let error = #expect(throws: DataGatewayClientError.self) { + try ArchebasePublicEndpoints.load(endpointsURL: endpointsURL) + } + + #expect(error == .endpointsNotInitialized(endpointsURL: endpointsURL.standardizedFileURL)) +} + +@Test func endpointInitializeWritesValidatedJSON() throws { + let root = try filePreparationTemporaryRoot() + let endpointsURL = root.appendingPathComponent(ArchebasePublicEndpoints.endpointsFileName) + + try DataGatewayClient.initialize(endpointsJSON: validEndpointsJSON(), endpointsURL: endpointsURL) + + #expect(FileManager.default.fileExists(atPath: endpointsURL.path())) + let endpoints = try ArchebasePublicEndpoints.load(endpointsURL: endpointsURL) + #expect(endpoints.gateway == URL(string: "http://gateway.example.com:50053")!) +} + +@Test func endpointInitializeRejectsInvalidJSONWithoutCreatingFile() throws { + let root = try filePreparationTemporaryRoot() + let endpointsURL = root.appendingPathComponent(ArchebasePublicEndpoints.endpointsFileName) + + #expect(throws: DataGatewayClientError.self) { + try DataGatewayClient.initialize(endpointsJSON: "{", endpointsURL: endpointsURL) } + + #expect(!FileManager.default.fileExists(atPath: endpointsURL.path())) +} + +@Test func endpointInitializeIsIdempotentForEquivalentEndpoints() throws { + let root = try filePreparationTemporaryRoot() + let endpointsURL = root.appendingPathComponent(ArchebasePublicEndpoints.endpointsFileName) + + try DataGatewayClient.initialize(endpointsJSON: validEndpointsJSON(), endpointsURL: endpointsURL) + try DataGatewayClient.initialize(endpointsJSON: validEndpointsJSON(), endpointsURL: endpointsURL) +} + +@Test func endpointInitializeRejectsDifferentExistingEndpoints() throws { + let root = try filePreparationTemporaryRoot() + let endpointsURL = root.appendingPathComponent(ArchebasePublicEndpoints.endpointsFileName) + + try DataGatewayClient.initialize(endpointsJSON: validEndpointsJSON(), endpointsURL: endpointsURL) + let error = #expect(throws: DataGatewayClientError.self) { + try DataGatewayClient.initialize( + endpointsJSON: validEndpointsJSON(authHost: "other-auth.example.com"), + endpointsURL: endpointsURL + ) + } + + #expect(error == .endpointsAlreadyInitialized(endpointsURL: endpointsURL.standardizedFileURL)) +} + +@Test func endpointInitializeRejectsCorruptExistingFile() throws { + let root = try filePreparationTemporaryRoot() + let endpointsURL = root.appendingPathComponent(ArchebasePublicEndpoints.endpointsFileName) + try Data("not-json".utf8).write(to: endpointsURL) + #expect(throws: DataGatewayClientError.self) { - try ArchebasePublicEndpoints.decodeResource(invalidPort) + try DataGatewayClient.initialize(endpointsJSON: validEndpointsJSON(), endpointsURL: endpointsURL) } + + #expect(String(data: try Data(contentsOf: endpointsURL), encoding: .utf8) == "not-json") } -@Test func publicClientConfigUsesResourceEndpointsAndDerivedTls() throws { - let root = URL(fileURLWithPath: "/tmp/archebase-public-config", isDirectory: true) - let config = DataGatewayClientConfig.recommended(credentialBase64: "credential-base64", persistRootURL: root) +@Test func publicClientConfigLoadsPersistedEndpoints() throws { + let root = try filePreparationTemporaryRoot() + let endpointsURL = root.appendingPathComponent(ArchebasePublicEndpoints.endpointsFileName) + try DataGatewayClient.initialize(endpointsJSON: validEndpointsJSON(), endpointsURL: endpointsURL) - #expect(config.authEndpoint == ArchebasePublicEndpoints.auth) - #expect(config.gatewayEndpoint == ArchebasePublicEndpoints.gateway) - #expect(config.authTLS == ArchebasePublicEndpoints.authTLS) - #expect(config.gatewayTLS == ArchebasePublicEndpoints.gatewayTLS) + let config = try DataGatewayClientConfig.recommended( + credentialBase64: "credential-base64", + persistRootURL: root, + endpointsURL: endpointsURL + ) + + #expect(config.authEndpoint == URL(string: "http://auth.example.com:50051")!) + #expect(config.gatewayEndpoint == URL(string: "http://gateway.example.com:50053")!) + #expect(config.authTLS == .plaintext) + #expect(config.gatewayTLS == .plaintext) #expect(config.credentialBase64 == "credential-base64") #expect(config.persistRootURL == root) #expect(throws: Never.self) { try config.validate() } } -@Test func publicDeviceInitConfigUsesTlsByDefault() throws { - let configURL = URL(fileURLWithPath: "/tmp/archebase-config.json") - let config = DeviceInitClientConfig(configURL: configURL) +@Test func publicClientConfigThrowsWhenEndpointsMissing() throws { + let root = try filePreparationTemporaryRoot() + let endpointsURL = root.appendingPathComponent(ArchebasePublicEndpoints.endpointsFileName) + + let error = #expect(throws: DataGatewayClientError.self) { + _ = try DataGatewayClientConfig.recommended( + credentialBase64: "credential-base64", + persistRootURL: root, + endpointsURL: endpointsURL + ) + } + + #expect(error == .endpointsNotInitialized(endpointsURL: endpointsURL.standardizedFileURL)) +} + +@Test func publicDeviceInitConfigStoresEndpointsURLWithoutLoadingIt() throws { + let root = try filePreparationTemporaryRoot() + let configURL = root.appendingPathComponent("archebase-config.json") + let endpointsURL = root.appendingPathComponent(ArchebasePublicEndpoints.endpointsFileName) + let config = DeviceInitClientConfig(configURL: configURL, endpointsURL: endpointsURL) #expect(config.configURL == configURL) - #expect(config.tls == ArchebasePublicEndpoints.deviceInitTLS) + #expect(config.endpointsURL == endpointsURL) + #expect(config.tls == nil) +} + +@Test func publicDeviceInitializerLoadsPersistedDeviceInitEndpoint() throws { + let root = try filePreparationTemporaryRoot() + let configURL = root.appendingPathComponent("archebase-config.json") + let endpointsURL = root.appendingPathComponent(ArchebasePublicEndpoints.endpointsFileName) + try DataGatewayClient.initialize(endpointsJSON: validEndpointsJSON(), endpointsURL: endpointsURL) + + _ = try ArchebaseDeviceInitializer( + config: DeviceInitClientConfig(configURL: configURL, endpointsURL: endpointsURL) + ) +} + +@Test func publicDeviceInitializerThrowsWhenEndpointsMissing() throws { + let root = try filePreparationTemporaryRoot() + let configURL = root.appendingPathComponent("archebase-config.json") + let endpointsURL = root.appendingPathComponent(ArchebasePublicEndpoints.endpointsFileName) + + let error = #expect(throws: DataGatewayClientError.self) { + _ = try ArchebaseDeviceInitializer( + config: DeviceInitClientConfig(configURL: configURL, endpointsURL: endpointsURL) + ) + } + + #expect(error == .endpointsNotInitialized(endpointsURL: endpointsURL.standardizedFileURL)) +} + +private func filePreparationTemporaryRoot() throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("archebase-file-preparation-tests", isDirectory: true) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root +} + +private func validEndpointsJSON(authHost: String = "auth.example.com") -> String { + """ + { + "auth": { "scheme": "http", "host": "\(authHost)", "port": 50051 }, + "gateway": { "scheme": "http", "host": "gateway.example.com", "port": 50053 }, + "deviceInit": { "scheme": "https", "host": "init.example.com", "port": 443 } + } + """ } private extension Dictionary { diff --git a/Tests/DataGatewayClientIntegrationTests/LocalStackHarnessTests.swift b/Tests/DataGatewayClientIntegrationTests/LocalStackHarnessTests.swift index 4c16eb6..c9b15de 100644 --- a/Tests/DataGatewayClientIntegrationTests/LocalStackHarnessTests.swift +++ b/Tests/DataGatewayClientIntegrationTests/LocalStackHarnessTests.swift @@ -752,7 +752,10 @@ struct LocalStackHarnessTests { let configURL = clientConfig.persistRootURL.appendingPathComponent("archebase-config.json") let initializer: ArchebaseDeviceInitializer if publicDNSIntegrationEnabled { - initializer = try ArchebaseDeviceInitializer(config: DeviceInitClientConfig(configURL: configURL)) + let endpointsURL = clientConfig.persistRootURL.appendingPathComponent(ArchebasePublicEndpoints.endpointsFileName) + initializer = try ArchebaseDeviceInitializer( + config: DeviceInitClientConfig(configURL: configURL, endpointsURL: endpointsURL) + ) } else { let initEndpoint = try requiredURLFromEnvironment("DGW_REAL_INIT_ENDPOINT") let initTLS: TLSMode = initEndpoint.scheme?.lowercased() == "https" ? .tls : .plaintext @@ -809,9 +812,17 @@ private struct RealGatewayHarness { private func uniqueRealClientConfig(from config: DataGatewayClientConfig, label: String) throws -> DataGatewayClientConfig { var copy = config + let originalPersistRoot = config.persistRootURL copy.persistRootURL = config.persistRootURL .appendingPathComponent("aliyun-real-\(label)-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: copy.persistRootURL, withIntermediateDirectories: true) + let originalEndpointsURL = originalPersistRoot.appendingPathComponent(ArchebasePublicEndpoints.endpointsFileName) + if FileManager.default.fileExists(atPath: originalEndpointsURL.path()) { + try FileManager.default.copyItem( + at: originalEndpointsURL, + to: copy.persistRootURL.appendingPathComponent(ArchebasePublicEndpoints.endpointsFileName) + ) + } return copy } diff --git a/Tests/DataGatewayClientIntegrationTests/ManualAliyunDeviceInitTests.swift b/Tests/DataGatewayClientIntegrationTests/ManualAliyunDeviceInitTests.swift new file mode 100644 index 0000000..e51cadd --- /dev/null +++ b/Tests/DataGatewayClientIntegrationTests/ManualAliyunDeviceInitTests.swift @@ -0,0 +1,76 @@ +import Foundation +import Testing + +@testable import DataGatewayClient + +private let manualAliyunDeviceInitEnabled = { + let environment = ProcessInfo.processInfo.environment + return environment["DGW_MANUAL_DEVICE_ID"]?.isEmpty == false + && environment["DGW_MANUAL_INIT_ENDPOINT"]?.isEmpty == false + && environment["DGW_MANUAL_CONFIG_URL"]?.isEmpty == false +}() + +@Suite(.serialized) +struct ManualAliyunDeviceInitTests { + @Test( + .enabled(if: manualAliyunDeviceInitEnabled) + ) func manualAliyunDeviceInitOnce() async throws { + let environment = ProcessInfo.processInfo.environment + let deviceID = try requiredEnvironment("DGW_MANUAL_DEVICE_ID", environment: environment) + let endpoint = try requiredURL("DGW_MANUAL_INIT_ENDPOINT", environment: environment) + let configPath = try requiredEnvironment("DGW_MANUAL_CONFIG_URL", environment: environment) + let configURL = URL(fileURLWithPath: configPath) + + if FileManager.default.fileExists(atPath: configURL.path()) { + try FileManager.default.removeItem(at: configURL) + } + + let tls: TLSMode = endpoint.scheme?.lowercased() == "https" ? .tls : .plaintext + let initializer = try ArchebaseDeviceInitializer( + config: DeviceInitClientConfig(configURL: configURL, tls: tls), + initEndpoint: endpoint, + sdkVersion: "manual-aliyun-device-init", + platform: "macos-codex" + ) + + let config = try await initializer.initDevice(deviceID: deviceID) + #expect(!config.apiKey.isEmpty) + print("MANUAL_DEVICE_INIT_CONFIG_URL=\(configURL.standardizedFileURL.path())") + print("MANUAL_DEVICE_INIT_TAG_KEYS=\(config.tags.keys.sorted().joined(separator: ","))") + } +} + +private func requiredEnvironment( + _ name: String, + environment: [String: String] +) throws -> String { + guard let value = environment[name]?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + throw ManualAliyunDeviceInitError.missingEnvironment(name) + } + return value +} + +private func requiredURL( + _ name: String, + environment: [String: String] +) throws -> URL { + let value = try requiredEnvironment(name, environment: environment) + guard let url = URL(string: value) else { + throw ManualAliyunDeviceInitError.invalidURL(name) + } + return url +} + +private enum ManualAliyunDeviceInitError: Error, CustomStringConvertible { + case missingEnvironment(String) + case invalidURL(String) + + var description: String { + switch self { + case .missingEnvironment(let name): + return "missing required environment variable: \(name)" + case .invalidURL(let name): + return "invalid URL in environment variable: \(name)" + } + } +} diff --git a/Tests/DataGatewayClientIntegrationTests/UploadCoordinatorTests.swift b/Tests/DataGatewayClientIntegrationTests/UploadCoordinatorTests.swift index aedf13c..63555fc 100644 --- a/Tests/DataGatewayClientIntegrationTests/UploadCoordinatorTests.swift +++ b/Tests/DataGatewayClientIntegrationTests/UploadCoordinatorTests.swift @@ -58,13 +58,11 @@ import Testing execution: .recommended, tls: .tls ) - let emptyCredential = DataGatewayClientConfig( + let emptyCredential = DataGatewayClientConfig.testRecommended( + authEndpoint: URL(string: "http://127.0.0.1:15055")!, + gatewayEndpoint: URL(string: "http://127.0.0.1:15053")!, credentialBase64: " ", - authRefreshBefore: .seconds(60), - requestTimeout: .seconds(10), - persistRootURL: persistRoot, - retryPolicy: .recommended, - execution: .recommended + persistRootURL: persistRoot ) #expect(throws: DataGatewayClientError.self) { try plaintextMismatch.validate() } From 11fa5df792173f935c7213363f99d0231496a770 Mon Sep 17 00:00:00 2001 From: Pengfei Wang Date: Fri, 8 May 2026 09:25:03 +0800 Subject: [PATCH 2/2] chore: refine README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d8af440..e4f3142 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ DGWStore ```swift dependencies: [ - .package(url: "https://github.com//data-sdk.git", from: "0.1.0") + .package(url: "https://github.com/archebase/data-sdk.git", from: "0.1.0") ] ```