Files
yattee/Yattee/Services/Networking/HTTPClient.swift
Arkadiusz Fal c0d38834a6 Let rows update their counts, drop fake dates, and own failed lookups
MAC6: a snapshot mutation carries its video count back to the list cache.
MAC8: playlist rows and cards are Buttons with the context menu attached,
so macOS accessibility can reach Edit and Delete.
MAC10: a published date stamped with the request's own time is dropped at
both API sentinels, and formattedPublishedDate refuses zero-interval output.
MAC11: a failed history hydration shows Details unavailable with a Try
Again action instead of keeping the raw video ID forever; an empty 200
body is a legible failure.
2026-09-11 20:28:35 +02:00

327 lines
12 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
/// Headers automatically applied to every request from this client.
/// Per-call `customHeaders` override these on key conflict.
/// Used to bake an HTTP Basic Auth `Authorization` header into a per-instance client
/// when the instance sits behind a reverse proxy that requires basic auth.
private var defaultHeaders: [String: String] = [:]
// MARK: - Initialization
/// - Parameters:
/// - userAgent: the User-Agent to send from the very first request. The
/// setter below cannot serve that purpose for a client built inside a
/// synchronous factory: awaiting it there is impossible, and scheduling
/// it races the first request out of the door.
/// - randomizeUserAgentPerRequest: whether to mint a fresh random
/// User-Agent per request instead, ignoring `userAgent`.
init(
session: URLSession = .shared,
decoder: JSONDecoder = .init(),
userAgent: String? = nil,
randomizeUserAgentPerRequest: Bool = false
) {
self.session = session
self.decoder = decoder
self.userAgent = userAgent
self.randomizeUserAgentPerRequest = randomizeUserAgentPerRequest
Self.configureForAPIs(self.decoder)
}
/// Applies the decoding rules every instance API response is parsed with.
///
/// Exposed so code that parses an already-fetched payload — a merged page
/// set, say — decodes it exactly the way the network path would.
nonisolated static func configureForAPIs(_ decoder: JSONDecoder) {
decoder.keyDecodingStrategy = .convertFromSnakeCase
decoder.dateDecodingStrategy = .iso8601
}
/// A decoder configured the way this client decodes API responses.
nonisolated static func makeAPIDecoder() -> JSONDecoder {
let decoder = JSONDecoder()
configureForAPIs(decoder)
return decoder
}
// 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
}
/// Sets headers to be applied to every request from this client.
/// Per-call `customHeaders` override these on key conflict.
func setDefaultHeaders(_ headers: [String: String]) {
self.defaultHeaders = headers
}
// 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 default headers first (e.g., basic auth bound to a per-instance client)
for (key, value) in defaultHeaders {
mutableRequest.setValue(value, forHTTPHeaderField: key)
}
// Apply custom headers (e.g., X-API-Key for authenticated requests).
// These override default headers on key conflict.
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)
// A body with nothing in it is a failed answer however the status line
// reads: instances do answer `200` with zero bytes, and that is not a
// response any of these types can be decoded from.
guard !data.isEmpty else {
Task { @MainActor in
LoggingService.shared.error("API returned an empty response body", category: .api)
}
throw APIError.decodingError("Empty response body")
}
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
}
}