mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 01:39:46 +00:00
Yattee v2 rewrite
This commit is contained in:
163
Yattee/Services/Networking/APIError.swift
Normal file
163
Yattee/Services/Networking/APIError.swift
Normal file
@@ -0,0 +1,163 @@
|
||||
//
|
||||
// APIError.swift
|
||||
// Yattee
|
||||
//
|
||||
// Typed errors for network operations.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Errors that can occur during API operations.
|
||||
enum APIError: Error, LocalizedError, Equatable, Sendable {
|
||||
/// The URL could not be constructed.
|
||||
case invalidURL
|
||||
|
||||
/// The request failed with an HTTP status code.
|
||||
case httpError(statusCode: Int, message: String?)
|
||||
|
||||
/// The response could not be decoded.
|
||||
case decodingError(String)
|
||||
|
||||
/// The request timed out.
|
||||
case timeout
|
||||
|
||||
/// No network connection available.
|
||||
case noConnection
|
||||
|
||||
/// The request was cancelled.
|
||||
case cancelled
|
||||
|
||||
/// Server returned an error message.
|
||||
case serverError(String)
|
||||
|
||||
/// Rate limited by the server.
|
||||
case rateLimited(retryAfter: TimeInterval?)
|
||||
|
||||
/// Authentication required or failed.
|
||||
case unauthorized
|
||||
|
||||
/// Resource not found, with optional server-provided detail message.
|
||||
case notFound(String?)
|
||||
|
||||
/// Comments are disabled for this video.
|
||||
case commentsDisabled
|
||||
|
||||
/// No suitable instance available.
|
||||
case noInstance
|
||||
|
||||
/// No playable streams available.
|
||||
case noStreams
|
||||
|
||||
/// The request parameters were invalid.
|
||||
case invalidRequest
|
||||
|
||||
/// Operation not supported by this instance type.
|
||||
case notSupported
|
||||
|
||||
/// An unknown error occurred.
|
||||
case unknown(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidURL:
|
||||
return "Invalid URL"
|
||||
case .httpError(let statusCode, let message):
|
||||
if let message {
|
||||
return message
|
||||
}
|
||||
return "HTTP error: \(statusCode)"
|
||||
case .decodingError(let message):
|
||||
return "Failed to decode response: \(message)"
|
||||
case .timeout:
|
||||
return "Request timed out"
|
||||
case .noConnection:
|
||||
return "No network connection"
|
||||
case .cancelled:
|
||||
return "Request was cancelled"
|
||||
case .serverError(let message):
|
||||
return "Server error: \(message)"
|
||||
case .rateLimited(let retryAfter):
|
||||
if let retryAfter {
|
||||
return "Rate limited. Retry after \(Int(retryAfter)) seconds"
|
||||
}
|
||||
return "Rate limited"
|
||||
case .unauthorized:
|
||||
return "Authentication required"
|
||||
case .notFound(let detail):
|
||||
if let detail {
|
||||
return detail
|
||||
}
|
||||
return "Resource not found"
|
||||
case .commentsDisabled:
|
||||
return "Comments are disabled"
|
||||
case .noInstance:
|
||||
return "No suitable instance available"
|
||||
case .noStreams:
|
||||
return "No playable streams available"
|
||||
case .invalidRequest:
|
||||
return "Invalid request"
|
||||
case .notSupported:
|
||||
return "Operation not supported"
|
||||
case .unknown(let message):
|
||||
return message
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this error is likely recoverable by retrying.
|
||||
var isRetryable: Bool {
|
||||
switch self {
|
||||
case .timeout, .noConnection, .rateLimited:
|
||||
return true
|
||||
case .httpError(let statusCode, _):
|
||||
return statusCode >= 500 || statusCode == 429
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
static func == (lhs: APIError, rhs: APIError) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.invalidURL, .invalidURL),
|
||||
(.timeout, .timeout),
|
||||
(.noConnection, .noConnection),
|
||||
(.cancelled, .cancelled),
|
||||
(.unauthorized, .unauthorized),
|
||||
(.commentsDisabled, .commentsDisabled),
|
||||
(.noInstance, .noInstance),
|
||||
(.noStreams, .noStreams),
|
||||
(.invalidRequest, .invalidRequest),
|
||||
(.notSupported, .notSupported):
|
||||
return true
|
||||
case (.notFound(let lDetail), .notFound(let rDetail)):
|
||||
return lDetail == rDetail
|
||||
case (.httpError(let lCode, let lMsg), .httpError(let rCode, let rMsg)):
|
||||
return lCode == rCode && lMsg == rMsg
|
||||
case (.decodingError(let lMsg), .decodingError(let rMsg)):
|
||||
return lMsg == rMsg
|
||||
case (.serverError(let lMsg), .serverError(let rMsg)):
|
||||
return lMsg == rMsg
|
||||
case (.rateLimited(let lRetry), .rateLimited(let rRetry)):
|
||||
return lRetry == rRetry
|
||||
case (.unknown(let lMsg), .unknown(let rMsg)):
|
||||
return lMsg == rMsg
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a decoding error from a Swift DecodingError.
|
||||
static func decodingError(_ error: DecodingError) -> APIError {
|
||||
switch error {
|
||||
case .typeMismatch(let type, let context):
|
||||
return .decodingError("Type mismatch for \(type): \(context.debugDescription)")
|
||||
case .valueNotFound(let type, let context):
|
||||
return .decodingError("Value not found for \(type): \(context.debugDescription)")
|
||||
case .keyNotFound(let key, let context):
|
||||
return .decodingError("Key '\(key.stringValue)' not found: \(context.debugDescription)")
|
||||
case .dataCorrupted(let context):
|
||||
return .decodingError("Data corrupted: \(context.debugDescription)")
|
||||
@unknown default:
|
||||
return .decodingError(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
135
Yattee/Services/Networking/Endpoint.swift
Normal file
135
Yattee/Services/Networking/Endpoint.swift
Normal file
@@ -0,0 +1,135 @@
|
||||
//
|
||||
// Endpoint.swift
|
||||
// Yattee
|
||||
//
|
||||
// Type-safe endpoint protocol for API requests.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// HTTP methods supported by the API.
|
||||
enum HTTPMethod: String, Sendable {
|
||||
case get = "GET"
|
||||
case post = "POST"
|
||||
case put = "PUT"
|
||||
case patch = "PATCH"
|
||||
case delete = "DELETE"
|
||||
}
|
||||
|
||||
/// Protocol defining an API endpoint.
|
||||
protocol Endpoint: Sendable {
|
||||
/// The path component of the URL (e.g., "/api/v1/videos").
|
||||
var path: String { get }
|
||||
|
||||
/// The HTTP method for this endpoint.
|
||||
var method: HTTPMethod { get }
|
||||
|
||||
/// Query parameters to append to the URL.
|
||||
var queryItems: [URLQueryItem]? { get }
|
||||
|
||||
/// HTTP headers to include in the request.
|
||||
var headers: [String: String]? { get }
|
||||
|
||||
/// The body data for POST/PUT/PATCH requests.
|
||||
var body: Data? { get }
|
||||
|
||||
/// Timeout interval for this specific request.
|
||||
var timeout: TimeInterval { get }
|
||||
}
|
||||
|
||||
// MARK: - Default Implementations
|
||||
|
||||
extension Endpoint {
|
||||
var method: HTTPMethod { .get }
|
||||
var queryItems: [URLQueryItem]? { nil }
|
||||
var headers: [String: String]? { nil }
|
||||
var body: Data? { nil }
|
||||
var timeout: TimeInterval { 30 }
|
||||
|
||||
/// Constructs a URLRequest from this endpoint and a base URL.
|
||||
func urlRequest(baseURL: URL) throws -> URLRequest {
|
||||
var components = URLComponents(url: baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: true)
|
||||
components?.queryItems = queryItems?.isEmpty == false ? queryItems : nil
|
||||
|
||||
guard let url = components?.url else {
|
||||
throw APIError.invalidURL
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = method.rawValue
|
||||
request.timeoutInterval = timeout
|
||||
|
||||
// Set default headers
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
|
||||
// Set custom headers
|
||||
headers?.forEach { key, value in
|
||||
request.setValue(value, forHTTPHeaderField: key)
|
||||
}
|
||||
|
||||
// Set body for non-GET requests
|
||||
if let body, method != .get {
|
||||
request.httpBody = body
|
||||
if request.value(forHTTPHeaderField: "Content-Type") == nil {
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
}
|
||||
}
|
||||
|
||||
return request
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Generic Endpoints
|
||||
|
||||
/// A generic endpoint that can be configured inline.
|
||||
struct GenericEndpoint: Endpoint, Sendable {
|
||||
let path: String
|
||||
let method: HTTPMethod
|
||||
let queryItems: [URLQueryItem]?
|
||||
let headers: [String: String]?
|
||||
let body: Data?
|
||||
let timeout: TimeInterval
|
||||
|
||||
init(
|
||||
path: String,
|
||||
method: HTTPMethod = .get,
|
||||
queryItems: [URLQueryItem]? = nil,
|
||||
headers: [String: String]? = nil,
|
||||
body: Data? = nil,
|
||||
timeout: TimeInterval = 30
|
||||
) {
|
||||
self.path = path
|
||||
self.method = method
|
||||
self.queryItems = queryItems
|
||||
self.headers = headers
|
||||
self.body = body
|
||||
self.timeout = timeout
|
||||
}
|
||||
|
||||
/// Creates a GET endpoint with query parameters.
|
||||
nonisolated static func get(_ path: String, query: [String: String] = [:]) -> GenericEndpoint {
|
||||
let queryItems = query.isEmpty ? nil : query.map { URLQueryItem(name: $0.key, value: $0.value) }
|
||||
return GenericEndpoint(path: path, queryItems: queryItems)
|
||||
}
|
||||
|
||||
/// Creates a GET endpoint with custom headers.
|
||||
nonisolated static func get(_ path: String, customHeaders: [String: String]) -> GenericEndpoint {
|
||||
return GenericEndpoint(path: path, headers: customHeaders)
|
||||
}
|
||||
|
||||
/// Creates a POST endpoint with an encodable body.
|
||||
nonisolated static func post<T: Encodable>(_ path: String, body: T, encoder: JSONEncoder = JSONEncoder()) -> GenericEndpoint {
|
||||
let bodyData = try? encoder.encode(body)
|
||||
return GenericEndpoint(path: path, method: .post, body: bodyData)
|
||||
}
|
||||
|
||||
/// Creates a POST endpoint without a body (e.g., for subscription endpoints).
|
||||
nonisolated static func post(_ path: String) -> GenericEndpoint {
|
||||
return GenericEndpoint(path: path, method: .post)
|
||||
}
|
||||
|
||||
/// Creates a DELETE endpoint.
|
||||
nonisolated static func delete(_ path: String) -> GenericEndpoint {
|
||||
return GenericEndpoint(path: path, method: .delete)
|
||||
}
|
||||
}
|
||||
270
Yattee/Services/Networking/HTTPClient.swift
Normal file
270
Yattee/Services/Networking/HTTPClient.swift
Normal file
@@ -0,0 +1,270 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
40
Yattee/Services/Networking/HTTPClientFactory.swift
Normal file
40
Yattee/Services/Networking/HTTPClientFactory.swift
Normal file
@@ -0,0 +1,40 @@
|
||||
//
|
||||
// HTTPClientFactory.swift
|
||||
// Yattee
|
||||
//
|
||||
// Factory for creating HTTPClient instances with appropriate SSL settings.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Factory for creating HTTPClient instances based on instance SSL settings.
|
||||
final class HTTPClientFactory: Sendable {
|
||||
private let sessionFactory: URLSessionFactory
|
||||
|
||||
init(sessionFactory: URLSessionFactory = .shared) {
|
||||
self.sessionFactory = sessionFactory
|
||||
}
|
||||
|
||||
/// Creates an HTTPClient configured for the given instance's SSL requirements.
|
||||
/// - Parameter instance: The instance to create a client for.
|
||||
/// - Returns: An HTTPClient with appropriate SSL settings.
|
||||
func createClient(for instance: Instance) -> HTTPClient {
|
||||
let session = sessionFactory.session(allowInvalidCertificates: instance.allowInvalidCertificates)
|
||||
return HTTPClient(session: session)
|
||||
}
|
||||
|
||||
/// Creates an HTTPClient with explicit SSL settings.
|
||||
/// - Parameter allowInvalidCertificates: Whether to bypass SSL certificate validation.
|
||||
/// - Returns: An HTTPClient with the specified SSL settings.
|
||||
func createClient(allowInvalidCertificates: Bool) -> HTTPClient {
|
||||
let session = sessionFactory.session(allowInvalidCertificates: allowInvalidCertificates)
|
||||
return HTTPClient(session: session)
|
||||
}
|
||||
|
||||
/// Creates an HTTPClient with low network priority for background/prefetch work.
|
||||
/// - Returns: An HTTPClient configured with `.background` network service type.
|
||||
func createLowPriorityClient() -> HTTPClient {
|
||||
let session = sessionFactory.lowPrioritySession()
|
||||
return HTTPClient(session: session)
|
||||
}
|
||||
}
|
||||
29
Yattee/Services/Networking/InsecureURLSessionDelegate.swift
Normal file
29
Yattee/Services/Networking/InsecureURLSessionDelegate.swift
Normal file
@@ -0,0 +1,29 @@
|
||||
//
|
||||
// InsecureURLSessionDelegate.swift
|
||||
// Yattee
|
||||
//
|
||||
// URLSessionDelegate that bypasses SSL certificate validation.
|
||||
// Used for connections to servers with self-signed or invalid certificates.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// URLSessionDelegate that accepts all server certificates, bypassing SSL validation.
|
||||
/// Only use this for trusted servers with self-signed certificates.
|
||||
final class InsecureURLSessionDelegate: NSObject, URLSessionDelegate {
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
didReceive challenge: URLAuthenticationChallenge,
|
||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||
) {
|
||||
// For server trust challenges, accept the certificate without validation
|
||||
if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
|
||||
let serverTrust = challenge.protectionSpace.serverTrust {
|
||||
let credential = URLCredential(trust: serverTrust)
|
||||
completionHandler(.useCredential, credential)
|
||||
} else {
|
||||
// For other authentication methods, use default handling
|
||||
completionHandler(.performDefaultHandling, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
61
Yattee/Services/Networking/URLSessionFactory.swift
Normal file
61
Yattee/Services/Networking/URLSessionFactory.swift
Normal file
@@ -0,0 +1,61 @@
|
||||
//
|
||||
// URLSessionFactory.swift
|
||||
// Yattee
|
||||
//
|
||||
// Factory for creating URLSession instances with appropriate SSL settings.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Factory for creating URLSession instances based on SSL validation requirements.
|
||||
final class URLSessionFactory: Sendable {
|
||||
/// Shared instance for app-wide use.
|
||||
static let shared = URLSessionFactory()
|
||||
|
||||
/// The delegate used for bypassing SSL certificate validation.
|
||||
private let insecureDelegate = InsecureURLSessionDelegate()
|
||||
|
||||
/// Cached insecure URLSession instance.
|
||||
private let insecureSession: URLSession
|
||||
|
||||
/// Cached low-priority URLSession for background/prefetch work.
|
||||
private let lowPriorityURLSession: URLSession
|
||||
|
||||
init() {
|
||||
let config = URLSessionConfiguration.default
|
||||
config.timeoutIntervalForRequest = 30
|
||||
config.timeoutIntervalForResource = 300
|
||||
|
||||
self.insecureSession = URLSession(
|
||||
configuration: config,
|
||||
delegate: insecureDelegate,
|
||||
delegateQueue: nil
|
||||
)
|
||||
|
||||
// Low-priority session for DeArrow and similar background work
|
||||
let lowPriorityConfig = URLSessionConfiguration.default
|
||||
lowPriorityConfig.timeoutIntervalForRequest = 30
|
||||
lowPriorityConfig.timeoutIntervalForResource = 300
|
||||
lowPriorityConfig.networkServiceType = .background
|
||||
lowPriorityConfig.waitsForConnectivity = true
|
||||
|
||||
self.lowPriorityURLSession = URLSession(configuration: lowPriorityConfig)
|
||||
}
|
||||
|
||||
/// Returns an appropriate URLSession based on SSL validation requirements.
|
||||
/// - Parameter allowInvalidCertificates: If true, returns a session that bypasses SSL validation.
|
||||
/// - Returns: A URLSession configured for the requested SSL mode.
|
||||
func session(allowInvalidCertificates: Bool) -> URLSession {
|
||||
if allowInvalidCertificates {
|
||||
return insecureSession
|
||||
} else {
|
||||
return URLSession.shared
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a low-priority URLSession for background/prefetch work.
|
||||
/// Uses `.background` network service type for OS-level bandwidth deprioritization.
|
||||
func lowPrioritySession() -> URLSession {
|
||||
lowPriorityURLSession
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user