Files
yattee/Yattee/Services/Networking/HTTPClient.swift
Arkadiusz Fal de4ee9a728 Give remote playlist rows a name of their own
Playlist fetches threw away the only handle a row could be removed by:
Invidious hands out an index/indexId pair per entry and both were dropped
on conversion, so nothing downstream could say which row it meant.

Both authenticated fetches now also return a snapshot — the playlist plus
its addressable entries. Invidious entries carry the server's indexId and
survive refetches; Piped has no such token, so its entries are addressed
by position and are only valid inside the snapshot that minted them. The
entry id carries the playlist id in both cases, so position 3 of one
playlist can never be mistaken for position 3 of another.

A row needs both an indexId and an index to be a target: rows missing
either are still displayed but never appear in entries, so no removal can
be aimed at a row the server refuses to name. Page-overlap dedupe stays
keyed on index and never on videoId — the same video may sit in a
playlist twice, and each copy is its own removable row.
2026-09-02 19:44:12 +02:00

303 lines
11 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
init(session: URLSession = .shared, decoder: JSONDecoder = .init()) {
self.session = session
self.decoder = decoder
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)
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
}
}