Files
yattee/Yattee/Services/Networking/HTTPClient.swift
2026-02-08 18:33:56 +01:00

271 lines
9.6 KiB
Swift

//
// HTTPClient.swift
// Yattee
//
// Modern async/await networking layer using URLSession.
//
import Foundation
/// Actor-based HTTP client for making network requests.
actor HTTPClient {
// MARK: - Properties
private let session: URLSession
private let decoder: JSONDecoder
private var userAgent: String?
private var randomizeUserAgentPerRequest: Bool = false
// MARK: - Initialization
init(session: URLSession = .shared, decoder: JSONDecoder = .init()) {
self.session = session
self.decoder = decoder
// Configure decoder for common API patterns
self.decoder.keyDecodingStrategy = .convertFromSnakeCase
self.decoder.dateDecodingStrategy = .iso8601
}
// MARK: - Configuration
/// Sets the User-Agent header to use for all requests.
/// - Parameter userAgent: The User-Agent string to use.
func setUserAgent(_ userAgent: String) {
self.userAgent = userAgent
}
/// Sets whether to generate a new random User-Agent for each request.
/// - Parameter enabled: If true, ignores the fixed userAgent and generates a new random one per request.
func setRandomizeUserAgentPerRequest(_ enabled: Bool) {
self.randomizeUserAgentPerRequest = enabled
}
// MARK: - Public Methods
/// Fetches and decodes a response from the given endpoint.
/// - Parameters:
/// - endpoint: The endpoint to fetch from.
/// - baseURL: The base URL to use for the request.
/// - customHeaders: Optional custom headers to add to the request (e.g., API keys).
/// - Returns: The decoded response.
func fetch<T: Decodable & Sendable>(
_ endpoint: Endpoint,
baseURL: URL,
customHeaders: [String: String]? = nil
) async throws -> T {
let request = try endpoint.urlRequest(baseURL: baseURL)
return try await perform(request, customHeaders: customHeaders)
}
/// Fetches raw data from the given endpoint.
/// - Parameters:
/// - endpoint: The endpoint to fetch from.
/// - baseURL: The base URL to use for the request.
/// - customHeaders: Optional custom headers to add to the request (e.g., API keys).
/// - Returns: The raw response data.
func fetchData(
_ endpoint: Endpoint,
baseURL: URL,
customHeaders: [String: String]? = nil
) async throws -> Data {
let request = try endpoint.urlRequest(baseURL: baseURL)
return try await performRaw(request, customHeaders: customHeaders)
}
/// Sends a request without expecting a response body.
/// Used for POST/PUT/DELETE operations that return empty responses.
/// - Parameters:
/// - endpoint: The endpoint to send the request to.
/// - baseURL: The base URL to use for the request.
/// - customHeaders: Optional custom headers to add to the request (e.g., cookies).
func sendRequest(
_ endpoint: Endpoint,
baseURL: URL,
customHeaders: [String: String]? = nil
) async throws {
let request = try endpoint.urlRequest(baseURL: baseURL)
_ = try await performRaw(request, customHeaders: customHeaders)
}
/// Performs a request and returns the raw data without decoding.
/// - Parameters:
/// - request: The URLRequest to perform.
/// - customHeaders: Optional custom headers to add to the request (e.g., API keys).
/// - Returns: The raw response data.
func performRaw(_ request: URLRequest, customHeaders: [String: String]? = nil) async throws -> Data {
var mutableRequest = request
// Apply User-Agent header
if randomizeUserAgentPerRequest {
// Generate a fresh random User-Agent for this request
mutableRequest.setValue(UserAgentGenerator.generateRandom(), forHTTPHeaderField: "User-Agent")
} else if let userAgent {
mutableRequest.setValue(userAgent, forHTTPHeaderField: "User-Agent")
}
// Apply custom headers (e.g., X-API-Key for authenticated requests)
if let customHeaders {
for (key, value) in customHeaders {
mutableRequest.setValue(value, forHTTPHeaderField: key)
}
}
let method = mutableRequest.httpMethod ?? "GET"
let requestURL = mutableRequest.url ?? URL(string: "unknown")!
let finalRequest = mutableRequest
await MainActor.run {
LoggingService.shared.logAPIRequest(method, url: requestURL)
}
// Check if task was already cancelled before making the request
if Task.isCancelled {
await MainActor.run {
LoggingService.shared.debug("Request cancelled before execution: \(requestURL)", category: .api)
}
throw APIError.cancelled
}
let startTime = Date()
let data: Data
let response: URLResponse
do {
(data, response) = try await session.data(for: finalRequest)
} catch let error as URLError {
let apiError = mapURLError(error)
// Add more context for cancelled requests
if error.code == .cancelled {
await MainActor.run {
LoggingService.shared.debug(
"URLSession request cancelled - Task.isCancelled: \(Task.isCancelled)",
category: .api
)
}
}
await MainActor.run {
LoggingService.shared.logAPIError(requestURL, error: apiError)
}
throw apiError
} catch {
await MainActor.run {
LoggingService.shared.logAPIError(requestURL, error: error)
}
throw APIError.unknown(error.localizedDescription)
}
let duration = Date().timeIntervalSince(startTime)
do {
try validateResponse(response, data: data)
} catch {
await MainActor.run {
LoggingService.shared.logAPIError(requestURL, error: error)
}
throw error
}
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0
await MainActor.run {
LoggingService.shared.logAPIResponse(requestURL, statusCode: statusCode, duration: duration)
}
return data
}
// MARK: - Private Methods
private func perform<T: Decodable & Sendable>(
_ request: URLRequest,
customHeaders: [String: String]? = nil
) async throws -> T {
let data = try await performRaw(request, customHeaders: customHeaders)
do {
return try decoder.decode(T.self, from: data)
} catch let error as DecodingError {
let errorDescription = describeDecodingError(error)
Task { @MainActor in
LoggingService.shared.error("API decoding error: \(errorDescription)", category: .api)
}
throw APIError.decodingError(errorDescription)
}
}
private func validateResponse(_ response: URLResponse, data: Data) throws {
guard let httpResponse = response as? HTTPURLResponse else {
return
}
let statusCode = httpResponse.statusCode
switch statusCode {
case 200...299:
return
case 401:
throw APIError.unauthorized
case 404:
let detail = parseErrorDetail(from: data)
throw APIError.notFound(detail)
case 429:
let retryAfter = httpResponse.value(forHTTPHeaderField: "Retry-After")
.flatMap { TimeInterval($0) }
throw APIError.rateLimited(retryAfter: retryAfter)
default:
let detail = parseErrorDetail(from: data)
throw APIError.httpError(statusCode: statusCode, message: detail)
}
}
private func mapURLError(_ error: URLError) -> APIError {
switch error.code {
case .timedOut:
return .timeout
case .notConnectedToInternet, .networkConnectionLost:
return .noConnection
case .cancelled:
return .cancelled
default:
return .unknown(error.localizedDescription)
}
}
private func describeDecodingError(_ error: DecodingError) -> String {
switch error {
case .keyNotFound(let key, let context):
return "Missing key '\(key.stringValue)' at \(context.codingPath.map(\.stringValue).joined(separator: "."))"
case .typeMismatch(let type, let context):
return "Type mismatch for \(type) at \(context.codingPath.map(\.stringValue).joined(separator: "."))"
case .valueNotFound(let type, let context):
return "Null value for \(type) at \(context.codingPath.map(\.stringValue).joined(separator: "."))"
case .dataCorrupted(let context):
return "Data corrupted at \(context.codingPath.map(\.stringValue).joined(separator: "."))"
@unknown default:
return error.localizedDescription
}
}
/// Parses error detail from JSON response body.
/// Supports common API error formats: {"detail": "..."}, {"error": "..."}, {"message": "..."}
private func parseErrorDetail(from data: Data) -> String? {
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return nil
}
// Try common error message fields
if let detail = json["detail"] as? String {
return detail
}
if let error = json["error"] as? String {
return error
}
if let message = json["message"] as? String {
return message
}
return nil
}
}