添加网络层并接入 v9 登录流程及单元测试
This commit is contained in:
264
suixinkan/Core/Networking/APIClient.swift
Normal file
264
suixinkan/Core/Networking/APIClient.swift
Normal file
@ -0,0 +1,264 @@
|
||||
//
|
||||
// APIClient.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// URLSession 抽象协议,用于让网络客户端支持测试替身。
|
||||
protocol URLSessionProtocol {
|
||||
/// 发起 URLRequest 并返回原始数据和响应。
|
||||
func data(for request: URLRequest) async throws -> (Data, URLResponse)
|
||||
}
|
||||
|
||||
extension URLSession: URLSessionProtocol {}
|
||||
|
||||
@MainActor
|
||||
/// 统一网络请求客户端,负责构造请求、注入 token、校验响应和解析业务 Envelope。
|
||||
final class APIClient {
|
||||
private let session: URLSessionProtocol
|
||||
private let encoder: JSONEncoder
|
||||
private let decoder: JSONDecoder
|
||||
private var authTokenProvider: (() -> String?)?
|
||||
|
||||
private let environment: APIEnvironment
|
||||
private let appVersion: String
|
||||
private let osType: String
|
||||
|
||||
/// 初始化网络客户端及其编码、解码和环境配置。
|
||||
init(
|
||||
environment: APIEnvironment = .current,
|
||||
session: URLSessionProtocol = APIClient.defaultSession,
|
||||
encoder: JSONEncoder = JSONEncoder(),
|
||||
decoder: JSONDecoder = JSONDecoder(),
|
||||
appVersion: String = AppClientInfo.appVersion(),
|
||||
osType: String = AppClientInfo.osType
|
||||
) {
|
||||
self.environment = environment
|
||||
self.session = session
|
||||
self.encoder = encoder
|
||||
self.decoder = decoder
|
||||
self.appVersion = appVersion.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "1.0.0"
|
||||
self.osType = osType.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? AppClientInfo.osType
|
||||
}
|
||||
|
||||
nonisolated private static let defaultSession: URLSession = {
|
||||
let configuration = URLSessionConfiguration.default
|
||||
configuration.timeoutIntervalForRequest = 12
|
||||
configuration.timeoutIntervalForResource = 20
|
||||
configuration.waitsForConnectivity = false
|
||||
return URLSession(configuration: configuration)
|
||||
}()
|
||||
|
||||
/// 绑定 token 提供者,让业务 API 不需要直接持有登录状态。
|
||||
func bindAuthTokenProvider(_ provider: @escaping () -> String?) {
|
||||
authTokenProvider = provider
|
||||
}
|
||||
|
||||
/// 发送强类型 APIRequest,并返回解包后的业务数据。
|
||||
func send<Response: Decodable>(
|
||||
_ apiRequest: APIRequest<Response>,
|
||||
tokenOverride: String? = nil
|
||||
) async throws -> Response {
|
||||
let request = try makeURLRequest(apiRequest, tokenOverride: tokenOverride)
|
||||
logRequest(request)
|
||||
|
||||
let data: Data
|
||||
let response: URLResponse
|
||||
do {
|
||||
(data, response) = try await session.data(for: request)
|
||||
} catch is CancellationError {
|
||||
logCancelled(for: request, reason: "CancellationError")
|
||||
throw CancellationError()
|
||||
} catch let error as URLError {
|
||||
if error.code == .cancelled {
|
||||
logCancelled(for: request, reason: "URLError.cancelled")
|
||||
throw CancellationError()
|
||||
}
|
||||
throw APIError.networkFailed(networkErrorMessage(for: error))
|
||||
} catch {
|
||||
throw APIError.networkFailed(error.localizedDescription)
|
||||
}
|
||||
|
||||
logResponse(for: request, response: response, data: data)
|
||||
do {
|
||||
try validateHTTPResponse(response, data: data)
|
||||
return try decodeEnvelope(Response.self, from: data)
|
||||
} catch let error as APIError {
|
||||
notifySessionExpiredIfNeeded(for: error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/// 将业务请求模型转换为 URLRequest,并注入公共 Header、token 和请求体。
|
||||
private func makeURLRequest<Response: Decodable>(
|
||||
_ apiRequest: APIRequest<Response>,
|
||||
tokenOverride: String?
|
||||
) throws -> URLRequest {
|
||||
let path = apiRequest.path.hasPrefix("/") ? apiRequest.path : "/" + apiRequest.path
|
||||
guard var components = URLComponents(
|
||||
string: environment.baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + path
|
||||
) else {
|
||||
throw APIError.invalidURL
|
||||
}
|
||||
|
||||
if !apiRequest.queryItems.isEmpty {
|
||||
components.queryItems = apiRequest.queryItems
|
||||
}
|
||||
|
||||
guard let url = components.url else {
|
||||
throw APIError.invalidURL
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = apiRequest.method.rawValue
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
request.setValue(appVersion, forHTTPHeaderField: "X-APP-VERSION")
|
||||
request.setValue(osType, forHTTPHeaderField: "X-OS-TYPE")
|
||||
|
||||
apiRequest.headers.forEach { key, value in
|
||||
request.setValue(value, forHTTPHeaderField: key)
|
||||
}
|
||||
|
||||
let token = tokenOverride ?? authTokenProvider?()
|
||||
if let token = token?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty {
|
||||
request.setValue(token, forHTTPHeaderField: "token")
|
||||
}
|
||||
|
||||
if let body = apiRequest.body {
|
||||
request.httpBody = try encoder.encode(body)
|
||||
}
|
||||
|
||||
return request
|
||||
}
|
||||
|
||||
/// 校验 HTTP 层响应状态码,非 2xx 时提取错误信息。
|
||||
private func validateHTTPResponse(_ response: URLResponse, data: Data) throws {
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw APIError.invalidResponse
|
||||
}
|
||||
|
||||
guard 200 ..< 300 ~= httpResponse.statusCode else {
|
||||
throw APIError.httpStatus(httpResponse.statusCode, parseHTTPErrorMessage(data: data))
|
||||
}
|
||||
}
|
||||
|
||||
/// 解码后端统一 Envelope,并返回内部 data 数据。
|
||||
private func decodeEnvelope<Response: Decodable>(_ responseType: Response.Type, from data: Data) throws -> Response {
|
||||
let envelope: APIEnvelope<Response>
|
||||
do {
|
||||
envelope = try decoder.decode(APIEnvelope<Response>.self, from: data)
|
||||
} catch {
|
||||
throw APIError.decodeFailed(error.localizedDescription)
|
||||
}
|
||||
|
||||
guard envelope.isSuccess else {
|
||||
throw APIError.serverCode(envelope.code, envelope.msg ?? "业务请求失败")
|
||||
}
|
||||
|
||||
if responseType == EmptyPayload.self {
|
||||
return EmptyPayload() as! Response
|
||||
}
|
||||
|
||||
guard let payload = envelope.data else {
|
||||
throw APIError.emptyData
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
/// Token 失效时广播 sessionDidExpire,触发全局登出。
|
||||
private func notifySessionExpiredIfNeeded(for error: APIError) {
|
||||
guard APIError.isAuthenticationExpired(error) else { return }
|
||||
NotificationCenter.default.post(name: NotificationName.sessionDidExpire, object: nil)
|
||||
}
|
||||
|
||||
/// 从 HTTP 错误响应中提取更适合展示给用户的错误信息。
|
||||
private func parseHTTPErrorMessage(data: Data) -> String {
|
||||
if let envelope = try? decoder.decode(ErrorEnvelope.self, from: data) {
|
||||
if let msg = envelope.msg?.trimmingCharacters(in: .whitespacesAndNewlines), !msg.isEmpty {
|
||||
return msg
|
||||
}
|
||||
if let message = envelope.message?.trimmingCharacters(in: .whitespacesAndNewlines), !message.isEmpty {
|
||||
return message
|
||||
}
|
||||
if let error = envelope.error?.trimmingCharacters(in: .whitespacesAndNewlines), !error.isEmpty {
|
||||
return error
|
||||
}
|
||||
}
|
||||
|
||||
if let plainText = String(data: data, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!plainText.isEmpty {
|
||||
return plainText.count > 120 ? String(plainText.prefix(120)) + "..." : plainText
|
||||
}
|
||||
|
||||
return "服务端返回错误"
|
||||
}
|
||||
|
||||
/// 将 URLError 转换成中文网络错误提示。
|
||||
private func networkErrorMessage(for error: URLError) -> String {
|
||||
switch error.code {
|
||||
case .timedOut:
|
||||
"请求超时,请稍后重试"
|
||||
case .notConnectedToInternet:
|
||||
"网络不可用,请检查网络连接"
|
||||
case .networkConnectionLost:
|
||||
"网络连接中断,请重试"
|
||||
case .cannotFindHost, .cannotConnectToHost, .dnsLookupFailed:
|
||||
"无法连接服务器,请稍后重试"
|
||||
default:
|
||||
error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 在 Debug 环境打印请求信息。
|
||||
private func logRequest(_ request: URLRequest) {
|
||||
#if DEBUG
|
||||
let method = request.httpMethod ?? "REQUEST"
|
||||
let url = request.url?.absoluteString ?? "<invalid url>"
|
||||
print("[API][Request] \(method) \(url)")
|
||||
#endif
|
||||
}
|
||||
|
||||
/// 在 Debug 环境打印响应状态和响应体。
|
||||
private func logResponse(for request: URLRequest, response: URLResponse, data: Data) {
|
||||
#if DEBUG
|
||||
let method = request.httpMethod ?? "REQUEST"
|
||||
let url = request.url?.absoluteString ?? "<invalid url>"
|
||||
let statusCode = (response as? HTTPURLResponse).map { String($0.statusCode) } ?? "unknown"
|
||||
let body = Self.debugResponseBody(from: data)
|
||||
print("[API][Response] \(method) \(url) status=\(statusCode)\n\(body)")
|
||||
#endif
|
||||
}
|
||||
|
||||
/// 在 Debug 环境打印被取消的请求信息。
|
||||
private func logCancelled(for request: URLRequest, reason: String) {
|
||||
#if DEBUG
|
||||
let method = request.httpMethod ?? "REQUEST"
|
||||
let url = request.url?.absoluteString ?? "<invalid url>"
|
||||
print("[API][Cancelled] \(method) \(url) reason=\(reason)")
|
||||
#endif
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
/// 将响应体格式化为便于调试阅读的字符串。
|
||||
private static func debugResponseBody(from data: Data) -> String {
|
||||
guard !data.isEmpty else { return "<empty response>" }
|
||||
if
|
||||
let object = try? JSONSerialization.jsonObject(with: data),
|
||||
let prettyData = try? JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys]),
|
||||
let prettyJSON = String(data: prettyData, encoding: .utf8) {
|
||||
return prettyJSON
|
||||
}
|
||||
return String(data: data, encoding: .utf8) ?? "<non-utf8 response: \(data.count) bytes>"
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var nonEmpty: String? {
|
||||
isEmpty ? nil : self
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user