APIClient.swift 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371
  1. import Foundation
  2. import Security
  3. enum HTTPMethod: String {
  4. case get = "GET"
  5. case post = "POST"
  6. case put = "PUT"
  7. case delete = "DELETE"
  8. }
  9. struct APIEnvelope<T: Decodable>: Decodable {
  10. let code: Int
  11. let message: String
  12. let data: T?
  13. }
  14. struct EmptyAPIData: Decodable { }
  15. enum APIError: LocalizedError {
  16. case invalidConfiguration
  17. case invalidResponse
  18. case unauthorized
  19. case server(code: Int, message: String)
  20. case transport(String)
  21. case decoding(String)
  22. case missingData
  23. var errorDescription: String? {
  24. switch self {
  25. case .invalidConfiguration: return "服务地址配置无效"
  26. case .invalidResponse: return "服务器返回了无效响应"
  27. case .unauthorized: return "登录状态已失效,请重新登录"
  28. case .server(_, let message): return message
  29. case .transport(let message): return "网络连接失败:\(message)"
  30. case .decoding(let message): return "服务器数据解析失败:\(message)"
  31. case .missingData: return "服务器响应缺少必要数据"
  32. }
  33. }
  34. }
  35. struct AuthTokens: Codable, Sendable {
  36. let accessToken: String
  37. let refreshToken: String
  38. let expiresAt: Date
  39. }
  40. final class TokenStore: @unchecked Sendable {
  41. static let shared = TokenStore()
  42. private let service = "com.celestia.trace.authentication"
  43. private let account = "current-session"
  44. func load() -> AuthTokens? {
  45. let query: [String: Any] = [
  46. kSecClass as String: kSecClassGenericPassword,
  47. kSecAttrService as String: service,
  48. kSecAttrAccount as String: account,
  49. kSecReturnData as String: true,
  50. kSecMatchLimit as String: kSecMatchLimitOne
  51. ]
  52. var result: CFTypeRef?
  53. guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
  54. let data = result as? Data else { return nil }
  55. return try? JSONDecoder().decode(AuthTokens.self, from: data)
  56. }
  57. func save(_ tokens: AuthTokens) throws {
  58. let data = try JSONEncoder().encode(tokens)
  59. let baseQuery: [String: Any] = [
  60. kSecClass as String: kSecClassGenericPassword,
  61. kSecAttrService as String: service,
  62. kSecAttrAccount as String: account
  63. ]
  64. let attributes: [String: Any] = [
  65. kSecValueData as String: data,
  66. kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
  67. ]
  68. let status = SecItemUpdate(baseQuery as CFDictionary, attributes as CFDictionary)
  69. if status == errSecItemNotFound {
  70. var insert = baseQuery
  71. attributes.forEach { insert[$0.key] = $0.value }
  72. guard SecItemAdd(insert as CFDictionary, nil) == errSecSuccess else {
  73. throw APIError.transport("无法安全保存登录凭据")
  74. }
  75. } else if status != errSecSuccess {
  76. throw APIError.transport("无法更新登录凭据")
  77. }
  78. }
  79. func clear() {
  80. let query: [String: Any] = [
  81. kSecClass as String: kSecClassGenericPassword,
  82. kSecAttrService as String: service,
  83. kSecAttrAccount as String: account
  84. ]
  85. SecItemDelete(query as CFDictionary)
  86. }
  87. }
  88. private struct RefreshPayload: Decodable {
  89. let token: String
  90. let refreshToken: String
  91. let expiresAt: Date
  92. }
  93. actor APIClient {
  94. static let shared = APIClient()
  95. private let baseURL: URL
  96. private let session: URLSession
  97. private let tokenStore: TokenStore
  98. init(
  99. baseURL: URL? = nil,
  100. session: URLSession? = nil,
  101. tokenStore: TokenStore = .shared
  102. ) {
  103. let configured = baseURL ?? (Bundle.main.object(forInfoDictionaryKey: "APIBaseURL") as? String).flatMap(URL.init(string:))
  104. self.baseURL = configured ?? URL(string: "https://celestia-trace.ccdw.life/v1")!
  105. if let session {
  106. self.session = session
  107. } else {
  108. let configuration = URLSessionConfiguration.default
  109. configuration.waitsForConnectivity = true
  110. configuration.timeoutIntervalForRequest = 30
  111. configuration.timeoutIntervalForResource = 300
  112. self.session = URLSession(configuration: configuration)
  113. }
  114. self.tokenStore = tokenStore
  115. }
  116. func hasStoredSession() -> Bool {
  117. tokenStore.load() != nil
  118. }
  119. func storeTokens(accessToken: String, refreshToken: String, expiresAt: Date) throws {
  120. try tokenStore.save(AuthTokens(accessToken: accessToken, refreshToken: refreshToken, expiresAt: expiresAt))
  121. }
  122. func clearSession() {
  123. tokenStore.clear()
  124. }
  125. func request<T: Decodable>(
  126. _ path: String,
  127. method: HTTPMethod = .get,
  128. body: Data? = nil,
  129. authenticated: Bool = false
  130. ) async throws -> T {
  131. let envelope: APIEnvelope<T> = try await execute(
  132. path,
  133. method: method,
  134. body: body,
  135. authenticated: authenticated,
  136. canRefresh: true
  137. )
  138. guard let data = envelope.data else { throw APIError.missingData }
  139. return data
  140. }
  141. func requestVoid(
  142. _ path: String,
  143. method: HTTPMethod,
  144. body: Data? = nil,
  145. authenticated: Bool = false
  146. ) async throws {
  147. let _: APIEnvelope<EmptyAPIData> = try await execute(
  148. path,
  149. method: method,
  150. body: body,
  151. authenticated: authenticated,
  152. canRefresh: true
  153. )
  154. }
  155. func upload<T: Decodable>(
  156. _ path: String,
  157. fileURL: URL,
  158. fileName: String,
  159. mimeType: String,
  160. fields: [String: String]
  161. ) async throws -> T {
  162. do {
  163. return try await performUpload(path, fileURL: fileURL, fileName: fileName, mimeType: mimeType, fields: fields)
  164. } catch APIError.unauthorized {
  165. try await refreshTokens()
  166. return try await performUpload(path, fileURL: fileURL, fileName: fileName, mimeType: mimeType, fields: fields)
  167. }
  168. }
  169. func download(_ path: String, to destinationURL: URL) async throws {
  170. do {
  171. try await performDownload(path, to: destinationURL)
  172. } catch APIError.unauthorized {
  173. try await refreshTokens()
  174. try await performDownload(path, to: destinationURL)
  175. }
  176. }
  177. private func execute<T: Decodable>(
  178. _ path: String,
  179. method: HTTPMethod,
  180. body: Data?,
  181. authenticated: Bool,
  182. canRefresh: Bool
  183. ) async throws -> APIEnvelope<T> {
  184. var request = try makeRequest(path: path, method: method, body: body)
  185. if authenticated {
  186. guard let accessToken = tokenStore.load()?.accessToken else { throw APIError.unauthorized }
  187. request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
  188. }
  189. let data: Data
  190. let response: URLResponse
  191. do {
  192. (data, response) = try await session.data(for: request)
  193. } catch {
  194. throw APIError.transport(error.localizedDescription)
  195. }
  196. guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
  197. if http.statusCode == 401, authenticated, canRefresh {
  198. try await refreshTokens()
  199. return try await execute(path, method: method, body: body, authenticated: true, canRefresh: false)
  200. }
  201. let envelope: APIEnvelope<T>
  202. do {
  203. envelope = try Self.decoder.decode(APIEnvelope<T>.self, from: data)
  204. } catch {
  205. throw APIError.decoding(error.localizedDescription)
  206. }
  207. guard (200..<300).contains(http.statusCode), envelope.code == 0 else {
  208. if http.statusCode == 401 { throw APIError.unauthorized }
  209. throw APIError.server(code: envelope.code, message: envelope.message)
  210. }
  211. return envelope
  212. }
  213. private func refreshTokens() async throws {
  214. guard let current = tokenStore.load() else { throw APIError.unauthorized }
  215. let body = try JSONSerialization.data(withJSONObject: ["refreshToken": current.refreshToken])
  216. let envelope: APIEnvelope<RefreshPayload> = try await execute(
  217. "auth/refresh",
  218. method: .post,
  219. body: body,
  220. authenticated: false,
  221. canRefresh: false
  222. )
  223. guard let payload = envelope.data else { throw APIError.unauthorized }
  224. try tokenStore.save(AuthTokens(accessToken: payload.token, refreshToken: payload.refreshToken, expiresAt: payload.expiresAt))
  225. }
  226. private func performUpload<T: Decodable>(
  227. _ path: String,
  228. fileURL: URL,
  229. fileName: String,
  230. mimeType: String,
  231. fields: [String: String]
  232. ) async throws -> T {
  233. guard let token = tokenStore.load()?.accessToken else { throw APIError.unauthorized }
  234. let boundary = "CelestiaBoundary-\(UUID().uuidString)"
  235. let temporaryURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".upload")
  236. guard FileManager.default.createFile(atPath: temporaryURL.path, contents: nil),
  237. let output = try? FileHandle(forWritingTo: temporaryURL) else {
  238. throw APIError.transport("无法准备上传文件")
  239. }
  240. defer {
  241. try? output.close()
  242. try? FileManager.default.removeItem(at: temporaryURL)
  243. }
  244. for (key, value) in fields.sorted(by: { $0.key < $1.key }) {
  245. try output.write(contentsOf: Data("--\(boundary)\r\nContent-Disposition: form-data; name=\"\(key)\"\r\n\r\n\(value)\r\n".utf8))
  246. }
  247. try output.write(contentsOf: Data("--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"\(fileName)\"\r\nContent-Type: \(mimeType)\r\n\r\n".utf8))
  248. let input = try FileHandle(forReadingFrom: fileURL)
  249. while let chunk = try input.read(upToCount: 1024 * 1024), !chunk.isEmpty {
  250. try output.write(contentsOf: chunk)
  251. }
  252. try input.close()
  253. try output.write(contentsOf: Data("\r\n--\(boundary)--\r\n".utf8))
  254. try output.synchronize()
  255. var request = try makeRequest(path: path, method: .post, body: nil)
  256. request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
  257. request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
  258. let data: Data
  259. let response: URLResponse
  260. do {
  261. (data, response) = try await session.upload(for: request, fromFile: temporaryURL)
  262. } catch {
  263. throw APIError.transport(error.localizedDescription)
  264. }
  265. guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
  266. if http.statusCode == 401 { throw APIError.unauthorized }
  267. let envelope: APIEnvelope<T>
  268. do {
  269. envelope = try Self.decoder.decode(APIEnvelope<T>.self, from: data)
  270. } catch {
  271. throw APIError.decoding(error.localizedDescription)
  272. }
  273. guard (200..<300).contains(http.statusCode), envelope.code == 0 else {
  274. throw APIError.server(code: envelope.code, message: envelope.message)
  275. }
  276. guard let result = envelope.data else { throw APIError.missingData }
  277. return result
  278. }
  279. private func performDownload(_ path: String, to destinationURL: URL) async throws {
  280. guard let token = tokenStore.load()?.accessToken else { throw APIError.unauthorized }
  281. var request = try makeRequest(path: path, method: .get, body: nil)
  282. request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
  283. let temporaryURL: URL
  284. let response: URLResponse
  285. do {
  286. (temporaryURL, response) = try await session.download(for: request)
  287. } catch {
  288. throw APIError.transport(error.localizedDescription)
  289. }
  290. guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
  291. if http.statusCode == 401 { throw APIError.unauthorized }
  292. guard (200..<300).contains(http.statusCode) else {
  293. throw APIError.server(code: http.statusCode, message: "云端文件下载失败")
  294. }
  295. let fileManager = FileManager.default
  296. try fileManager.createDirectory(at: destinationURL.deletingLastPathComponent(), withIntermediateDirectories: true)
  297. if fileManager.fileExists(atPath: destinationURL.path) {
  298. return
  299. }
  300. do {
  301. try fileManager.moveItem(at: temporaryURL, to: destinationURL)
  302. } catch {
  303. throw APIError.transport("无法保存云端文件:\(error.localizedDescription)")
  304. }
  305. }
  306. private func makeRequest(path: String, method: HTTPMethod, body: Data?) throws -> URLRequest {
  307. let pathAndQuery = path.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false)
  308. let cleanPath = String(pathAndQuery[0]).trimmingCharacters(in: CharacterSet(charactersIn: "/"))
  309. let pathURL = cleanPath.split(separator: "/").reduce(baseURL) { partial, component in
  310. partial.appendingPathComponent(String(component))
  311. }
  312. var components = URLComponents(url: pathURL, resolvingAgainstBaseURL: false)
  313. if pathAndQuery.count == 2 {
  314. components?.percentEncodedQuery = String(pathAndQuery[1])
  315. }
  316. guard let url = components?.url else { throw APIError.invalidConfiguration }
  317. var request = URLRequest(url: url)
  318. request.httpMethod = method.rawValue
  319. request.setValue("application/json", forHTTPHeaderField: "Accept")
  320. if let body {
  321. request.httpBody = body
  322. request.setValue("application/json", forHTTPHeaderField: "Content-Type")
  323. }
  324. return request
  325. }
  326. static let decoder: JSONDecoder = {
  327. let decoder = JSONDecoder()
  328. decoder.dateDecodingStrategy = .custom { decoder in
  329. let container = try decoder.singleValueContainer()
  330. let value = try container.decode(String.self)
  331. let formatter = ISO8601DateFormatter()
  332. formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
  333. if let date = formatter.date(from: value) { return date }
  334. formatter.formatOptions = [.withInternetDateTime]
  335. if let date = formatter.date(from: value) { return date }
  336. throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid ISO-8601 date")
  337. }
  338. return decoder
  339. }()
  340. }