629 lines
20 KiB
Swift
629 lines
20 KiB
Swift
|
||
|
||
import Foundation
|
||
import Alamofire
|
||
import Combine
|
||
import UIKit
|
||
|
||
/// Enhanced API Client with advanced features
|
||
class APIClient {
|
||
|
||
// MARK: - Properties
|
||
|
||
// < static let shared = APIClient()
|
||
|
||
private let session: Session
|
||
private let decoder: JSONDecoder
|
||
private let encoder: JSONEncoder
|
||
private let interceptor: AuthorizationInterceptor
|
||
private let logger = NetworkLogger.self
|
||
|
||
// Cache for images
|
||
private let imageCache = NSCache<NSString, UIImage>()
|
||
|
||
// Request tracking for cancellation
|
||
private var activeRequests: [String: DataRequest] = [:]
|
||
private let requestLock = NSLock()
|
||
|
||
// MARK: - Configuration
|
||
|
||
struct Configuration {
|
||
var timeout: TimeInterval = 30
|
||
var cachePolicy: URLRequest.CachePolicy = .reloadIgnoringLocalCacheData
|
||
var retryLimit: Int = 3
|
||
var logLevel: NetworkLogger.LogLevel = .debug
|
||
var enableCaching: Bool = true
|
||
var maxConcurrentRequests: Int = 5
|
||
}
|
||
|
||
private(set) var configuration = Configuration()
|
||
|
||
// MARK: - Initialization
|
||
|
||
init() {
|
||
// Custom decoder with flexible date handling
|
||
self.decoder = Self.createJSONDecoder()
|
||
self.encoder = Self.createJSONEncoder()
|
||
|
||
// Configure session
|
||
let sessionConfiguration = URLSessionConfiguration.default
|
||
sessionConfiguration.timeoutIntervalForRequest = configuration.timeout
|
||
sessionConfiguration.timeoutIntervalForResource = configuration.timeout * 2
|
||
sessionConfiguration.requestCachePolicy = configuration.cachePolicy
|
||
sessionConfiguration.httpMaximumConnectionsPerHost = configuration.maxConcurrentRequests
|
||
|
||
// Create interceptor
|
||
self.interceptor = AuthorizationInterceptor()
|
||
|
||
// Create session with event monitors
|
||
let eventMonitor = APIEventMonitor()
|
||
self.session = Session(
|
||
configuration: sessionConfiguration,
|
||
interceptor: interceptor,
|
||
eventMonitors: [eventMonitor]
|
||
)
|
||
|
||
// Configure logger
|
||
logger.logLevel = configuration.logLevel
|
||
|
||
// Configure image cache
|
||
imageCache.countLimit = 100
|
||
imageCache.totalCostLimit = 50 * 1024 * 1024 // 50MB
|
||
}
|
||
|
||
// MARK: - Configuration Methods
|
||
|
||
func configure(_ config: Configuration) {
|
||
self.configuration = config
|
||
logger.logLevel = config.logLevel
|
||
}
|
||
|
||
func setAuthToken(_ token: String?) {
|
||
interceptor.setAccessToken(token)
|
||
}
|
||
|
||
// MARK: - JSON Coders
|
||
|
||
private static func createJSONDecoder() -> JSONDecoder {
|
||
let decoder = JSONDecoder()
|
||
|
||
// Flexible date decoding strategy
|
||
decoder.dateDecodingStrategy = .custom { decoder in
|
||
let container = try decoder.singleValueContainer()
|
||
let dateStr = try container.decode(String.self)
|
||
|
||
// Handle microseconds format (6 decimal places)
|
||
if let date = parseISO8601WithMicroseconds(dateStr) {
|
||
return date
|
||
}
|
||
|
||
// Try multiple date formats
|
||
let formatters = [
|
||
ISO8601DateFormatter.internetDateTimeWithFractionalSeconds,
|
||
ISO8601DateFormatter.internetDateTime,
|
||
DateFormatter.iso8601Full,
|
||
DateFormatter.iso8601
|
||
]
|
||
|
||
for formatter in formatters {
|
||
if let date = (formatter as? ISO8601DateFormatter)?.date(from: dateStr) ??
|
||
(formatter as? DateFormatter)?.date(from: dateStr) {
|
||
return date
|
||
}
|
||
}
|
||
|
||
throw DecodingError.dataCorruptedError(
|
||
in: container,
|
||
debugDescription: "Cannot decode date string: \(dateStr)"
|
||
)
|
||
}
|
||
|
||
return decoder
|
||
}
|
||
|
||
// Add this helper function
|
||
private static func parseISO8601WithMicroseconds(_ dateString: String) -> Date? {
|
||
let formatter = DateFormatter()
|
||
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
|
||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||
formatter.timeZone = TimeZone(secondsFromGMT: 0)
|
||
|
||
if let date = formatter.date(from: dateString) {
|
||
return date
|
||
}
|
||
|
||
// Try with timezone if present
|
||
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSSZ"
|
||
return formatter.date(from: dateString)
|
||
}
|
||
|
||
private static func createJSONEncoder() -> JSONEncoder {
|
||
let encoder = JSONEncoder()
|
||
encoder.dateEncodingStrategy = .iso8601
|
||
// encoder.keyEncodingStrategy = .convertToSnakeCase
|
||
return encoder
|
||
}
|
||
|
||
// MARK: - Request Methods
|
||
|
||
/// Generic request with Encodable parameters and Decodable response
|
||
@discardableResult
|
||
func request<Parameters: Encodable, Response: Decodable>(
|
||
_ url: URLConvertible,
|
||
method: HTTPMethod = .get,
|
||
parameters: Parameters? = nil,
|
||
headers: HTTPHeaders? = nil,
|
||
requestId: String? = nil
|
||
) async throws -> Response {
|
||
let startTime = Date()
|
||
let id = requestId ?? UUID().uuidString
|
||
|
||
// Log JSON payload before sending
|
||
/*
|
||
if let parameters = parameters {
|
||
do {
|
||
let jsonData = try encoder.encode(parameters)
|
||
if let jsonString = String(data: jsonData, encoding: .utf8) {
|
||
fluxlog("📤 Request JSON Body:")
|
||
print(jsonString)
|
||
}
|
||
} catch {
|
||
fluxlog("⚠️ Failed to encode parameters for logging: \(error)")
|
||
}
|
||
}
|
||
*/
|
||
let request = session.request(
|
||
url,
|
||
method: method,
|
||
parameters: parameters,
|
||
encoder: JSONParameterEncoder(encoder: encoder),
|
||
headers: headers,
|
||
interceptor: interceptor
|
||
)
|
||
.validate()
|
||
|
||
// Rest of the method remains the same
|
||
trackRequest(request, id: id)
|
||
defer { removeRequest(id: id) }
|
||
|
||
if let urlRequest = request.request {
|
||
logger.logRequest(urlRequest)
|
||
|
||
// Log the actual request body if available
|
||
if let httpBody = urlRequest.httpBody,
|
||
let bodyString = String(data: httpBody, encoding: .utf8) {
|
||
fluxlog("📤 Actual Request Body:")
|
||
print(bodyString)
|
||
}
|
||
}
|
||
|
||
let response = await request.serializingDecodable(Response.self, decoder: decoder).response
|
||
let duration = Date().timeIntervalSince(startTime)
|
||
|
||
switch response.result {
|
||
case .success(let value):
|
||
if let httpResponse = response.response {
|
||
logger.logResponse(httpResponse, data: response.data, duration: duration)
|
||
}
|
||
return value
|
||
|
||
case .failure(let error):
|
||
logger.logError(error, request: response.request, data: response.data, duration: duration)
|
||
throw mapError(error, response: response.response, data: response.data)
|
||
}
|
||
}
|
||
|
||
/// Request with dictionary parameters
|
||
@discardableResult
|
||
func request<Response: Decodable>(
|
||
_ url: URLConvertible,
|
||
method: HTTPMethod = .get,
|
||
parameters: [String: Any]? = nil,
|
||
encoding: ParameterEncoding = JSONEncoding.default,
|
||
headers: HTTPHeaders? = nil,
|
||
requestId: String? = nil
|
||
) async throws -> Response {
|
||
let startTime = Date()
|
||
let id = requestId ?? UUID().uuidString
|
||
|
||
let request = session.request(
|
||
url,
|
||
method: method,
|
||
parameters: parameters,
|
||
encoding: encoding,
|
||
headers: headers,
|
||
interceptor: interceptor
|
||
)
|
||
.validate()
|
||
|
||
trackRequest(request, id: id)
|
||
defer { removeRequest(id: id) }
|
||
|
||
if let urlRequest = request.request {
|
||
logger.logRequest(urlRequest)
|
||
}
|
||
|
||
let response = await request.serializingDecodable(Response.self, decoder: decoder).response
|
||
let duration = Date().timeIntervalSince(startTime)
|
||
|
||
switch response.result {
|
||
case .success(let value):
|
||
if let httpResponse = response.response {
|
||
logger.logResponse(httpResponse, data: response.data, duration: duration)
|
||
}
|
||
return value
|
||
|
||
case .failure(let error):
|
||
logger.logError(error, request: response.request, data: response.data, duration: duration)
|
||
throw mapError(error, response: response.response, data: response.data)
|
||
}
|
||
}
|
||
|
||
/// Request with no response body expected
|
||
func requestVoid<Parameters: Encodable>(
|
||
_ url: URLConvertible,
|
||
method: HTTPMethod = .get,
|
||
parameters: Parameters? = nil,
|
||
headers: HTTPHeaders? = nil,
|
||
requestId: String? = nil
|
||
) async throws {
|
||
let startTime = Date()
|
||
let id = requestId ?? UUID().uuidString
|
||
|
||
let request = session.request(
|
||
url,
|
||
method: method,
|
||
parameters: parameters,
|
||
encoder: JSONParameterEncoder(encoder: encoder),
|
||
headers: headers,
|
||
interceptor: interceptor
|
||
)
|
||
.validate()
|
||
|
||
trackRequest(request, id: id)
|
||
defer { removeRequest(id: id) }
|
||
|
||
if let urlRequest = request.request {
|
||
logger.logRequest(urlRequest)
|
||
}
|
||
|
||
// Use Empty.self with emptyResponseCodes including 200 to handle
|
||
// servers that return 200 OK with an empty body (zero-length data)
|
||
let response = await request
|
||
.serializingDecodable(
|
||
Empty.self,
|
||
emptyResponseCodes: [200, 201, 204, 205]
|
||
)
|
||
.response
|
||
let duration = Date().timeIntervalSince(startTime)
|
||
|
||
switch response.result {
|
||
case .success:
|
||
if let httpResponse = response.response {
|
||
logger.logResponse(httpResponse, data: response.data, duration: duration)
|
||
}
|
||
|
||
case .failure(let error):
|
||
logger.logError(error, request: response.request, data: response.data, duration: duration)
|
||
throw mapError(error, response: response.response, data: response.data)
|
||
}
|
||
}
|
||
|
||
/// Download image with caching
|
||
func downloadImage(
|
||
from url: URLConvertible,
|
||
cacheKey: String? = nil,
|
||
headers: HTTPHeaders? = nil,
|
||
requestId: String? = nil
|
||
) async throws -> UIImage {
|
||
// Check cache first
|
||
let key = cacheKey ?? (try? url.asURL().absoluteString) ?? UUID().uuidString
|
||
if configuration.enableCaching, let cachedImage = imageCache.object(forKey: key as NSString) {
|
||
return cachedImage
|
||
}
|
||
|
||
let startTime = Date()
|
||
let id = requestId ?? UUID().uuidString
|
||
|
||
let request = session.request(
|
||
url,
|
||
method: .get,
|
||
headers: headers,
|
||
interceptor: interceptor
|
||
)
|
||
.validate()
|
||
|
||
trackRequest(request, id: id)
|
||
defer { removeRequest(id: id) }
|
||
|
||
if let urlRequest = request.request {
|
||
logger.logRequest(urlRequest)
|
||
}
|
||
|
||
let response = await request.serializingData().response
|
||
let duration = Date().timeIntervalSince(startTime)
|
||
|
||
switch response.result {
|
||
case .success(let data):
|
||
if let httpResponse = response.response {
|
||
logger.logResponse(httpResponse, data: data, duration: duration)
|
||
}
|
||
|
||
guard let image = UIImage(data: data) else {
|
||
throw APIError.decodingFailed(NSError(domain: "ImageDecoding", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to create UIImage from data"]))
|
||
}
|
||
|
||
// Cache the image
|
||
if configuration.enableCaching {
|
||
imageCache.setObject(image, forKey: key as NSString, cost: data.count)
|
||
}
|
||
|
||
return image
|
||
|
||
case .failure(let error):
|
||
logger.logError(error, request: response.request, data: response.data, duration: duration)
|
||
throw mapError(error, response: response.response, data: response.data)
|
||
}
|
||
}
|
||
|
||
/// Download file to disk
|
||
func downloadFile(
|
||
from url: URLConvertible,
|
||
to destination: URL,
|
||
headers: HTTPHeaders? = nil,
|
||
progressHandler: ((Double) -> Void)? = nil
|
||
) async throws {
|
||
let request = session.download(
|
||
url,
|
||
method: .get,
|
||
headers: headers,
|
||
interceptor: interceptor,
|
||
to: { _, _ in (destination, [.removePreviousFile, .createIntermediateDirectories]) }
|
||
)
|
||
.validate()
|
||
|
||
if let progressHandler = progressHandler {
|
||
request.downloadProgress { progress in
|
||
progressHandler(progress.fractionCompleted)
|
||
}
|
||
}
|
||
|
||
let response = await request.serializingDownload(using: .url).response
|
||
|
||
switch response.result {
|
||
case .success:
|
||
break
|
||
case .failure(let error):
|
||
throw mapError(error, response: response.response, data: nil)
|
||
}
|
||
}
|
||
|
||
/// Upload multipart form data
|
||
func upload<Response: Decodable>(
|
||
_ url: URLConvertible,
|
||
multipartFormData: @escaping (MultipartFormData) -> Void,
|
||
headers: HTTPHeaders? = nil,
|
||
progressHandler: ((Double) -> Void)? = nil
|
||
) async throws -> Response {
|
||
let startTime = Date()
|
||
|
||
let request = session.upload(
|
||
multipartFormData: multipartFormData,
|
||
to: url,
|
||
headers: headers,
|
||
interceptor: interceptor
|
||
)
|
||
.validate()
|
||
|
||
if let progressHandler = progressHandler {
|
||
request.uploadProgress { progress in
|
||
progressHandler(progress.fractionCompleted)
|
||
}
|
||
}
|
||
|
||
let response = await request.serializingDecodable(Response.self, decoder: decoder).response
|
||
let duration = Date().timeIntervalSince(startTime)
|
||
|
||
switch response.result {
|
||
case .success(let value):
|
||
if let httpResponse = response.response {
|
||
logger.logResponse(httpResponse, data: response.data, duration: duration)
|
||
}
|
||
return value
|
||
|
||
case .failure(let error):
|
||
logger.logError(error, request: response.request, data: response.data, duration: duration)
|
||
throw mapError(error, response: response.response, data: response.data)
|
||
}
|
||
}
|
||
|
||
/// Stream a local file as raw bytes in the request body (no multipart).
|
||
/// Matches the backend's `POST /upload?filename=…` endpoint which reads
|
||
/// directly from `HttpServletRequest.getInputStream()`.
|
||
///
|
||
/// - Parameters:
|
||
/// - url: The endpoint URL (include query params if needed).
|
||
/// - fileURL: Local file to stream.
|
||
/// - headers: Optional extra headers. `Content-Type` defaults to
|
||
/// `application/octet-stream` if not provided.
|
||
/// - progressHandler: Called on the main queue with 0…1 fraction.
|
||
func uploadFile(
|
||
to url: URLConvertible,
|
||
fileURL: URL,
|
||
headers: HTTPHeaders? = nil,
|
||
progressHandler: ((Double) -> Void)? = nil
|
||
) async throws {
|
||
let startTime = Date()
|
||
|
||
var finalHeaders = headers ?? HTTPHeaders()
|
||
if finalHeaders["Content-Type"] == nil {
|
||
finalHeaders.add(.contentType("application/octet-stream"))
|
||
}
|
||
|
||
let request = session.upload(
|
||
fileURL,
|
||
to: url,
|
||
method: .post,
|
||
headers: finalHeaders,
|
||
interceptor: interceptor
|
||
)
|
||
.validate()
|
||
|
||
if let progressHandler = progressHandler {
|
||
request.uploadProgress { progress in
|
||
DispatchQueue.main.async {
|
||
progressHandler(progress.fractionCompleted)
|
||
}
|
||
}
|
||
}
|
||
|
||
if let urlRequest = request.request {
|
||
logger.logRequest(urlRequest)
|
||
}
|
||
|
||
let response = await request
|
||
.serializingDecodable(Empty.self, emptyResponseCodes: [200, 201, 204, 205])
|
||
.response
|
||
let duration = Date().timeIntervalSince(startTime)
|
||
|
||
switch response.result {
|
||
case .success:
|
||
if let httpResponse = response.response {
|
||
logger.logResponse(httpResponse, data: response.data, duration: duration)
|
||
}
|
||
case .failure(let error):
|
||
logger.logError(error, request: response.request, data: response.data, duration: duration)
|
||
throw mapError(error, response: response.response, data: response.data)
|
||
}
|
||
}
|
||
|
||
// MARK: - Request Management
|
||
|
||
func cancelRequest(id: String) {
|
||
requestLock.lock()
|
||
defer { requestLock.unlock() }
|
||
activeRequests[id]?.cancel()
|
||
activeRequests.removeValue(forKey: id)
|
||
}
|
||
|
||
func cancelAllRequests() {
|
||
requestLock.lock()
|
||
defer { requestLock.unlock() }
|
||
activeRequests.values.forEach { $0.cancel() }
|
||
activeRequests.removeAll()
|
||
}
|
||
|
||
private func trackRequest(_ request: DataRequest, id: String) {
|
||
requestLock.lock()
|
||
defer { requestLock.unlock() }
|
||
activeRequests[id] = request
|
||
}
|
||
|
||
private func removeRequest(id: String) {
|
||
requestLock.lock()
|
||
defer { requestLock.unlock() }
|
||
activeRequests.removeValue(forKey: id)
|
||
}
|
||
|
||
// MARK: - Cache Management
|
||
|
||
func clearImageCache() {
|
||
imageCache.removeAllObjects()
|
||
}
|
||
|
||
func clearImageCache(forKey key: String) {
|
||
imageCache.removeObject(forKey: key as NSString)
|
||
}
|
||
|
||
// MARK: - Error Mapping
|
||
|
||
private func mapError(_ error: AFError, response: HTTPURLResponse?, data: Data?) -> Error {
|
||
if error.isSessionTaskError {
|
||
if let underlyingError = error.underlyingError as? URLError {
|
||
switch underlyingError.code {
|
||
case .timedOut:
|
||
return APIError.timeout
|
||
case .cancelled:
|
||
return APIError.cancelled
|
||
default:
|
||
return APIError.networkError(underlyingError)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Handle serialization errors first — e.g. empty body on 200 OK
|
||
if error.isResponseSerializationError {
|
||
// If the server responded with a 2xx, don't treat this as a real error
|
||
if let statusCode = response?.statusCode, (200..<300).contains(statusCode) {
|
||
return APIError.cancelled // treat as no-op, caller discards this
|
||
}
|
||
return APIError.decodingFailed(error)
|
||
}
|
||
|
||
if let statusCode = response?.statusCode {
|
||
switch statusCode {
|
||
case 200..<300:
|
||
// 2xx with unexpected shape — not a real error
|
||
return APIError.cancelled
|
||
case 401:
|
||
return APIError.unauthorized
|
||
case 403:
|
||
return APIError.forbidden
|
||
case 404:
|
||
return APIError.notFound
|
||
case 500...599:
|
||
return APIError.serverError(statusCode: statusCode)
|
||
default:
|
||
return ServerError(statusCode: statusCode, data: data, response: response)
|
||
}
|
||
}
|
||
|
||
return APIError.unexpectedResponse
|
||
}
|
||
}
|
||
|
||
// MARK: - Event Monitor
|
||
|
||
private class APIEventMonitor: EventMonitor {
|
||
func requestDidResume(_ request: Request) {
|
||
// Track metrics if needed
|
||
}
|
||
|
||
func request(_ request: Request, didCreateTask task: URLSessionTask) {
|
||
// Track metrics if needed
|
||
}
|
||
}
|
||
|
||
// MARK: - Date Formatter Extensions
|
||
|
||
private extension ISO8601DateFormatter {
|
||
static let internetDateTime: ISO8601DateFormatter = {
|
||
let formatter = ISO8601DateFormatter()
|
||
formatter.formatOptions = [.withInternetDateTime]
|
||
return formatter
|
||
}()
|
||
|
||
static let internetDateTimeWithFractionalSeconds: ISO8601DateFormatter = {
|
||
let formatter = ISO8601DateFormatter()
|
||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||
return formatter
|
||
}()
|
||
}
|
||
|
||
private extension DateFormatter {
|
||
static let iso8601: DateFormatter = {
|
||
let formatter = DateFormatter()
|
||
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'"
|
||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||
formatter.timeZone = TimeZone(secondsFromGMT: 0)
|
||
return formatter
|
||
}()
|
||
|
||
static let iso8601Full: DateFormatter = {
|
||
let formatter = DateFormatter()
|
||
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ"
|
||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||
return formatter
|
||
}()
|
||
}
|