134 lines
4.5 KiB
Swift
134 lines
4.5 KiB
Swift
import Foundation
|
||
|
||
enum HTTPClientError: LocalizedError {
|
||
case invalidURL
|
||
case badStatus(Int, String)
|
||
|
||
var errorDescription: String? {
|
||
switch self {
|
||
case .invalidURL:
|
||
return "无效的服务地址"
|
||
case .badStatus(let code, let body):
|
||
return "HTTP \(code): \(body)"
|
||
}
|
||
}
|
||
}
|
||
|
||
struct HTTPResult {
|
||
let statusCode: Int
|
||
let body: Data
|
||
let effectiveBaseURL: String
|
||
}
|
||
|
||
enum HTTPClient {
|
||
static func getJSON<T: Decodable>(
|
||
_ type: T.Type,
|
||
baseURL: String,
|
||
path: String,
|
||
timeout: TimeInterval
|
||
) async throws -> (value: T, effectiveBaseURL: String) {
|
||
let result = try await execute(
|
||
baseURL: baseURL,
|
||
path: path,
|
||
method: "GET",
|
||
timeout: timeout,
|
||
headers: ["Accept": "application/json"]
|
||
)
|
||
guard (200...299).contains(result.statusCode) else {
|
||
let body = String(data: result.body, encoding: .utf8) ?? ""
|
||
throw HTTPClientError.badStatus(result.statusCode, body)
|
||
}
|
||
let value = try JSONDecoder().decode(T.self, from: result.body)
|
||
return (value, result.effectiveBaseURL)
|
||
}
|
||
|
||
static func execute(
|
||
baseURL: String,
|
||
path: String,
|
||
method: String,
|
||
timeout: TimeInterval,
|
||
headers: [String: String] = [:],
|
||
body: Data? = nil
|
||
) async throws -> HTTPResult {
|
||
let trimmedBase = baseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||
guard let base = URL(string: trimmedBase), base.scheme != nil, base.host != nil else {
|
||
throw HTTPClientError.invalidURL
|
||
}
|
||
let endpoint = URL(string: path, relativeTo: base) ?? base
|
||
do {
|
||
return try await perform(
|
||
url: endpoint,
|
||
method: method,
|
||
timeout: timeout,
|
||
headers: headers,
|
||
body: body,
|
||
effectiveBaseURL: trimmedBase
|
||
)
|
||
} catch {
|
||
// 与安卓端一致:HTTPS 因 TLS 问题失败时,降级用同一请求重试 HTTP。
|
||
guard AppConfig.allowHTTPFallback,
|
||
endpoint.scheme == "https",
|
||
isTLSError(error) else {
|
||
throw error
|
||
}
|
||
var baseComponents = URLComponents(string: trimmedBase)
|
||
baseComponents?.scheme = "http"
|
||
guard let httpBase = baseComponents?.string,
|
||
let httpURL = URL(string: path, relativeTo: URL(string: httpBase)) else {
|
||
throw error
|
||
}
|
||
return try await perform(
|
||
url: httpURL,
|
||
method: method,
|
||
timeout: timeout,
|
||
headers: headers,
|
||
body: body,
|
||
effectiveBaseURL: httpBase
|
||
)
|
||
}
|
||
}
|
||
|
||
private static func perform(
|
||
url: URL,
|
||
method: String,
|
||
timeout: TimeInterval,
|
||
headers: [String: String],
|
||
body: Data?,
|
||
effectiveBaseURL: String
|
||
) async throws -> HTTPResult {
|
||
var request = URLRequest(url: url)
|
||
request.httpMethod = method
|
||
request.timeoutInterval = timeout
|
||
request.setValue(AppConfig.userAgent, forHTTPHeaderField: "User-Agent")
|
||
for (key, value) in headers {
|
||
request.setValue(value, forHTTPHeaderField: key)
|
||
}
|
||
request.httpBody = body
|
||
let (data, response) = try await URLSession.shared.data(for: request)
|
||
guard let http = response as? HTTPURLResponse else {
|
||
throw HTTPClientError.invalidURL
|
||
}
|
||
return HTTPResult(statusCode: http.statusCode, body: data, effectiveBaseURL: effectiveBaseURL)
|
||
}
|
||
|
||
private static func isTLSError(_ error: Error) -> Bool {
|
||
let nsError = error as NSError
|
||
let tlsCodes: [Int] = [
|
||
NSURLErrorSecureConnectionFailed,
|
||
NSURLErrorServerCertificateHasBadDate,
|
||
NSURLErrorServerCertificateUntrusted,
|
||
NSURLErrorServerCertificateHasUnknownRoot,
|
||
NSURLErrorServerCertificateNotYetValid,
|
||
NSURLErrorClientCertificateRejected,
|
||
NSURLErrorClientCertificateRequired,
|
||
NSURLErrorCannotLoadFromNetwork,
|
||
]
|
||
if tlsCodes.contains(nsError.code) {
|
||
return true
|
||
}
|
||
let message = (nsError.localizedDescription + " " + (nsError.localizedFailureReason ?? ""))
|
||
.lowercased()
|
||
return message.contains("ssl") || message.contains("tls") || message.contains("certificate")
|
||
}
|
||
}
|