medmap-ios/medmap/Network/APIClient.swift
2026-08-27 23:26:14 +01:00

629 lines
20 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}()
}