Files
yattee/Yattee/Services/Networking/HTTPClient.swift
Arkadiusz Fal b2741bbb32 Let an account change the playlists it owns
Reading a server-side playlist was all the app could do: the write half of
both APIs was missing, and the only thing that knew which credential to use
was the subscription layer. So playlists arrive here as an account concern —
hand AccountService an account and it finds the instance, reads the secret,
refuses what the backend cannot do, and records what the answer implies
about the login.

Capabilities decide, never the instance type: a privacy setting sent to a
backend that has none is dropped rather than refused, because the playlist
is still perfectly creatable there. Every call captures the credential it
used and re-reads it before recording an outcome, so a late 401 cannot
reject the login the user just fixed, nor a late success clear a rejection
it never earned.

Removal is where the backends stop being alike. Invidious names the row
itself with an indexId that survives refetches, so it removes directly.
Piped removes by position, and a position is only true of the fetch that
minted it — so the playlist is refetched and the position checked to still
hold the same video before anything is deleted. That narrows the window; it
cannot close it, and only the refetch afterwards shows what really happened.

The cache exists so that refetch cannot be forgotten. Every mutation goes
through it and republishes what it touched — on failure as much as on
success, because a write that reported failure may still have landed — and
nothing is ever edited optimistically. It is in memory alone: the data
belongs to a login rather than to this device, and a stored Piped entry id
would be aimed at whatever occupies that slot next launch. A per-account
generation fences results that come back after the account was cleared,
since clearing has never cancelled the request that was filling it.

Along the way, clients minted per instance sent no User-Agent at all —
instances that whitelist the Yattee prefix answered them with a 401 the
shared client never earned. The factory now bakes the app's User-Agent in
at construction, where no request can outrun it.
2026-09-02 23:29:13 +02:00

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