From 9581e49ce06876cb95f9859cd55934ceba56137c Mon Sep 17 00:00:00 2001 From: Daniel Arantes Loverde Date: Sat, 29 Aug 2026 17:09:09 -0300 Subject: [PATCH] [api-upload-refactor] Convert API to an actor, drop @MainActor - API: @MainActor struct -> actor. certData/certPassword/persistConnectionDelay move from static var (data race) to actor-isolated instance state - setupCertificationRequest -> setupCertification; add setPersistConnectionDelay - add internal init(testConfiguration:) seam; makeSession() helper picks test / cert-delegate / shared session - URLSessionDelegateHandler: final + @unchecked Sendable, immutable let certs, no @MainActor; challenge logic unchanged - getIdentity(): remove as! SecIdentity, extract via typed Unmanaged bridging - delete unused Result enum and defaultParams - APIActorTests: actor conformance, delay round-trip, per-instance cert state --- .../Classes/LCEssentials+API.swift | 186 +++++++++++------- Tests/LCEssentialsTests/APIActorTests.swift | 26 +++ 2 files changed, 139 insertions(+), 73 deletions(-) create mode 100644 Tests/LCEssentialsTests/APIActorTests.swift diff --git a/Sources/LCEssentials/Classes/LCEssentials+API.swift b/Sources/LCEssentials/Classes/LCEssentials+API.swift index 1f76106..26312cc 100644 --- a/Sources/LCEssentials/Classes/LCEssentials+API.swift +++ b/Sources/LCEssentials/Classes/LCEssentials+API.swift @@ -29,14 +29,6 @@ import Security import UIKit #endif -/// A generic `Result` enumeration to represent either a success `Value` or a failure `Error`. -public enum Result { - /// Indicates a successful operation with an associated `Value`. - case success(Value) - /// Indicates a failed operation with an associated `Error`. - case failure(Error) -} - /// Enumeration defining common HTTP methods. public enum httpMethod: String { /// The POST method. @@ -51,39 +43,63 @@ public enum httpMethod: String { case delete = "DELETE" } -/// Loverde Co.: API generic struct for simple requests. +/// Loverde Co.: API entry point for simple requests. /// -/// This struct provides a convenient way to perform network requests with various configurations, -/// including handling different HTTP methods, parameter encoding, and certificate-based authentication. +/// An `actor`, so callers are never forced onto the main thread. It performs +/// network requests with various configurations, handling different HTTP +/// methods, typed request bodies, multipart uploads, and certificate-based +/// authentication. @available(iOS 13.0.0, *) -@MainActor -public struct API { - - private static var certData: Data? - private static var certPassword: String? - +public actor API { + + /// Client-certificate data (`.p12`) for mutual-TLS, if configured. + private var certData: Data? + /// Password for `certData`, if any. + private var certPassword: String? + + /// Session configuration used to build `URLSession`s. `nil` in production + /// (the shared session / a dedicated cert session is used); injected by + /// tests to register a stub `URLProtocol`. + private let sessionConfiguration: URLSessionConfiguration? + /// The default error used when an unexpected issue occurs during a request. - static let defaultError = NSError.createErrorWith(code: LCEssentials.DEFAULT_ERROR_CODE, - description: LCEssentials.DEFAULT_ERROR_MSG, - reasonForError: LCEssentials.DEFAULT_ERROR_MSG) - + nonisolated static var defaultError: NSError { + NSError.createErrorWith(code: LCEssentials.DEFAULT_ERROR_CODE, + description: LCEssentials.DEFAULT_ERROR_MSG, + reasonForError: LCEssentials.DEFAULT_ERROR_MSG) + } + /// The delay in seconds before retrying a persistent connection request. - public static var persistConnectionDelay: Double = 3 - - /// Default parameters that will be included in all requests unless explicitly overridden. - public static var defaultParams: [String:Any] = [String: Any]() - + public private(set) var persistConnectionDelay: Double = 3 + /// Default HTTP headers for requests. /// /// By default, it includes "Accept", "Content-Type", and "Accept-Encoding" headers. - var defaultHeaders: [String: String] = ["Accept": "application/json", - "Content-Type": "application/json; charset=UTF-8", - "Accept-Encoding": "gzip"] - - /// The shared singleton instance of the `API` struct. + nonisolated let defaultHeaders: [String: String] = ["Accept": "application/json", + "Content-Type": "application/json; charset=UTF-8", + "Accept-Encoding": "gzip"] + + /// The shared singleton instance of `API`. public static let shared = API() - - private init(){} + + private init() { + self.sessionConfiguration = nil + } + + /// Test-only. Builds an isolated instance whose `URLSession`s use + /// `configuration` — register a stub `URLProtocol` on it. Never call from + /// production code; `shared` state is untouched. + init(testConfiguration configuration: URLSessionConfiguration) { + self.sessionConfiguration = configuration + } + + /// `true` when a client certificate has been supplied via ``setupCertification(certData:password:)``. + var hasClientCertificateConfigured: Bool { certData != nil } + + /// Overrides ``persistConnectionDelay``. + public func setPersistConnectionDelay(_ seconds: Double) { + persistConnectionDelay = seconds + } /// Performs an asynchronous network request and decodes the response into a `Codable` type. /// @@ -194,28 +210,9 @@ public struct API { API.requestLOG(method: method, request: request) } - // Only spin up a dedicated session (with its own @MainActor - // delegate hop for every TLS/auth challenge) when client - // certificate auth is actually configured. Creating one of - // these per request unconditionally — and never invalidating - // it — could stall the async challenge callback waiting on an - // already-busy MainActor, hanging the request indefinitely with - // no timeout or error ever surfacing. The common case (no - // client cert) uses the shared session, which has none of this - // risk and is what URLSession is designed to be reused as. - let usesCertSession = API.certData != nil - let session: URLSession = usesCertSession - ? URLSession( - configuration: .default, - delegate: URLSessionDelegateHandler( - certData: API.certData, - password: API.certPassword - ), - delegateQueue: nil - ) - : URLSession.shared + let (session, mustInvalidate) = makeSession() defer { - if usesCertSession { + if mustInvalidate { session.finishTasksAndInvalidate() } } @@ -292,33 +289,63 @@ public struct API { /// - Parameters: /// - certData: The `Data` representation of the client certificate (e.g., a .p12 file). /// - password: The password for the certificate, if required. Defaults to an empty string. - public func setupCertificationRequest(certData: Data, password: String = "") { - API.certData = certData - API.certPassword = password + public func setupCertification(certData: Data, password: String = "") { + self.certData = certData + self.certPassword = password + } + + /// Builds the `URLSession` for one request. + /// + /// - A test configuration (if injected) always wins, carrying the cert + /// delegate when one is configured. + /// - Otherwise a dedicated, delegate-backed session is created only when a + /// client certificate is configured, and must be invalidated afterwards. + /// - The common no-cert case reuses `URLSession.shared`. + /// + /// - Returns: the session and whether the caller must invalidate it. + private func makeSession() -> (session: URLSession, mustInvalidate: Bool) { + #if canImport(Security) + let delegate: URLSessionDelegateHandler? = certData != nil + ? URLSessionDelegateHandler(certData: certData, password: certPassword) + : nil + #else + let delegate: URLSessionDelegate? = nil + #endif + + if let configuration = sessionConfiguration { + return (URLSession(configuration: configuration, delegate: delegate, delegateQueue: nil), true) + } + if delegate != nil { + return (URLSession(configuration: .default, delegate: delegate, delegateQueue: nil), true) + } + return (URLSession.shared, false) } } #if canImport(Security) /// A custom `URLSessionDelegate` handler for managing URL session challenges, /// particularly for client and server trust authentication. +/// +/// Immutable after `init`, so it is safe to hand to `URLSession` and have its +/// challenge callback invoked on any thread. The auth logic touches only the +/// Security framework, which is thread-safe. @available(iOS 13.0.0, *) -@MainActor -private class URLSessionDelegateHandler: NSObject, URLSessionDelegate { - - private var certData: Data? - private var certPass: String? - +private final class URLSessionDelegateHandler: NSObject, URLSessionDelegate, @unchecked Sendable { + + private let certData: Data? + private let certPass: String? + /// Initializes a new `URLSessionDelegateHandler` instance. /// /// - Parameters: /// - certData: Optional `Data` for the client certificate. /// - password: Optional password for the client certificate. init(certData: Data? = nil, password: String? = nil) { - super.init() self.certData = certData self.certPass = password + super.init() } - + /// Handles URL session authentication challenges. /// /// This method is responsible for providing client certificates for client certificate @@ -362,17 +389,30 @@ private class URLSessionDelegateHandler: NSObject, URLSessionDelegate { // Import the .p12 certificate to get the identity let status = SecPKCS12Import(certData as CFData, options as CFDictionary, &items) - - if status == errSecSuccess, - let item = (items as? [[String: Any]])?.first, - let identityRef = item[kSecImportItemIdentity as String] as CFTypeRef?, - CFGetTypeID(identityRef) == SecIdentityGetTypeID() { - return (identityRef as! SecIdentity) - } else { - print("Erro ao importar a identidade do certificado: \(status)") + + guard status == errSecSuccess, + let items, + CFArrayGetCount(items) > 0, + let itemPtr = CFArrayGetValueAtIndex(items, 0) else { + printError(title: "Certificate", msg: "Failed to import client identity from .p12 (status: \(status))") return nil } + // Read the identity straight out of the CF result via typed `Unmanaged` + // bridging — no `as!`. `SecPKCS12Import` boxes a CoreFoundation ref that + // Swift will not let us downcast without a force operation. + let itemDict = Unmanaged.fromOpaque(itemPtr).takeUnretainedValue() + let identityKey = Unmanaged.passUnretained(kSecImportItemIdentity).toOpaque() + guard let identityPtr = CFDictionaryGetValue(itemDict, identityKey) else { + printError(title: "Certificate", msg: "Imported .p12 contained no client identity") + return nil + } + let identity = Unmanaged.fromOpaque(identityPtr).takeUnretainedValue() + guard CFGetTypeID(identity) == SecIdentityGetTypeID() else { + printError(title: "Certificate", msg: "Imported .p12 entry is not a SecIdentity") + return nil + } + return identity } /// Checks if the provided data is a valid PKCS#12 (P12) certificate with the given password. diff --git a/Tests/LCEssentialsTests/APIActorTests.swift b/Tests/LCEssentialsTests/APIActorTests.swift new file mode 100644 index 0000000..59115dc --- /dev/null +++ b/Tests/LCEssentialsTests/APIActorTests.swift @@ -0,0 +1,26 @@ +import XCTest +@testable import LCEssentials + +final class APIActorTests: XCTestCase { + + func testAPIIsAnActorNotMainActorBound() async { + let isActor = (API.shared as Any) is any Actor + XCTAssertTrue(isActor, "API must be an actor so callers are not forced onto the main thread") + } + + func testPersistConnectionDelayRoundTrips() async { + let api = API(testConfiguration: .ephemeral) + await api.setPersistConnectionDelay(9) + let value = await api.persistConnectionDelay + XCTAssertEqual(value, 9) + } + + func testIsolatedTestInstanceDoesNotTouchSharedCertState() async { + let api = API(testConfiguration: .ephemeral) + await api.setupCertification(certData: Data([0x01, 0x02]), password: "pw") + let sharedHasCert = await API.shared.hasClientCertificateConfigured + let instanceHasCert = await api.hasClientCertificateConfigured + XCTAssertFalse(sharedHasCert) + XCTAssertTrue(instanceHasCert) + } +}