[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
This commit is contained in:
Daniel Arantes Loverde
2026-08-29 17:09:09 -03:00
parent e89935a69c
commit 9581e49ce0
2 changed files with 139 additions and 73 deletions

View File

@@ -29,14 +29,6 @@ import Security
import UIKit import UIKit
#endif #endif
/// A generic `Result` enumeration to represent either a success `Value` or a failure `Error`.
public enum Result<Value, Error: Swift.Error> {
/// 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. /// Enumeration defining common HTTP methods.
public enum httpMethod: String { public enum httpMethod: String {
/// The POST method. /// The POST method.
@@ -51,39 +43,63 @@ public enum httpMethod: String {
case delete = "DELETE" 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, /// An `actor`, so callers are never forced onto the main thread. It performs
/// including handling different HTTP methods, parameter encoding, and certificate-based authentication. /// network requests with various configurations, handling different HTTP
/// methods, typed request bodies, multipart uploads, and certificate-based
/// authentication.
@available(iOS 13.0.0, *) @available(iOS 13.0.0, *)
@MainActor public actor API {
public struct API {
private static var certData: Data? /// Client-certificate data (`.p12`) for mutual-TLS, if configured.
private static var certPassword: String? 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. /// The default error used when an unexpected issue occurs during a request.
static let defaultError = NSError.createErrorWith(code: LCEssentials.DEFAULT_ERROR_CODE, nonisolated static var defaultError: NSError {
NSError.createErrorWith(code: LCEssentials.DEFAULT_ERROR_CODE,
description: LCEssentials.DEFAULT_ERROR_MSG, description: LCEssentials.DEFAULT_ERROR_MSG,
reasonForError: LCEssentials.DEFAULT_ERROR_MSG) reasonForError: LCEssentials.DEFAULT_ERROR_MSG)
}
/// The delay in seconds before retrying a persistent connection request. /// The delay in seconds before retrying a persistent connection request.
public static var persistConnectionDelay: Double = 3 public private(set) var persistConnectionDelay: Double = 3
/// Default parameters that will be included in all requests unless explicitly overridden.
public static var defaultParams: [String:Any] = [String: Any]()
/// Default HTTP headers for requests. /// Default HTTP headers for requests.
/// ///
/// By default, it includes "Accept", "Content-Type", and "Accept-Encoding" headers. /// By default, it includes "Accept", "Content-Type", and "Accept-Encoding" headers.
var defaultHeaders: [String: String] = ["Accept": "application/json", nonisolated let defaultHeaders: [String: String] = ["Accept": "application/json",
"Content-Type": "application/json; charset=UTF-8", "Content-Type": "application/json; charset=UTF-8",
"Accept-Encoding": "gzip"] "Accept-Encoding": "gzip"]
/// The shared singleton instance of the `API` struct. /// The shared singleton instance of `API`.
public static let shared = 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. /// 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) API.requestLOG(method: method, request: request)
} }
// Only spin up a dedicated session (with its own @MainActor let (session, mustInvalidate) = makeSession()
// 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
defer { defer {
if usesCertSession { if mustInvalidate {
session.finishTasksAndInvalidate() session.finishTasksAndInvalidate()
} }
} }
@@ -292,21 +289,51 @@ public struct API {
/// - Parameters: /// - Parameters:
/// - certData: The `Data` representation of the client certificate (e.g., a .p12 file). /// - 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. /// - password: The password for the certificate, if required. Defaults to an empty string.
public func setupCertificationRequest(certData: Data, password: String = "") { public func setupCertification(certData: Data, password: String = "") {
API.certData = certData self.certData = certData
API.certPassword = password 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) #if canImport(Security)
/// A custom `URLSessionDelegate` handler for managing URL session challenges, /// A custom `URLSessionDelegate` handler for managing URL session challenges,
/// particularly for client and server trust authentication. /// 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, *) @available(iOS 13.0.0, *)
@MainActor private final class URLSessionDelegateHandler: NSObject, URLSessionDelegate, @unchecked Sendable {
private class URLSessionDelegateHandler: NSObject, URLSessionDelegate {
private var certData: Data? private let certData: Data?
private var certPass: String? private let certPass: String?
/// Initializes a new `URLSessionDelegateHandler` instance. /// Initializes a new `URLSessionDelegateHandler` instance.
/// ///
@@ -314,9 +341,9 @@ private class URLSessionDelegateHandler: NSObject, URLSessionDelegate {
/// - certData: Optional `Data` for the client certificate. /// - certData: Optional `Data` for the client certificate.
/// - password: Optional password for the client certificate. /// - password: Optional password for the client certificate.
init(certData: Data? = nil, password: String? = nil) { init(certData: Data? = nil, password: String? = nil) {
super.init()
self.certData = certData self.certData = certData
self.certPass = password self.certPass = password
super.init()
} }
/// Handles URL session authentication challenges. /// Handles URL session authentication challenges.
@@ -363,16 +390,29 @@ private class URLSessionDelegateHandler: NSObject, URLSessionDelegate {
// Import the .p12 certificate to get the identity // Import the .p12 certificate to get the identity
let status = SecPKCS12Import(certData as CFData, options as CFDictionary, &items) let status = SecPKCS12Import(certData as CFData, options as CFDictionary, &items)
if status == errSecSuccess, guard status == errSecSuccess,
let item = (items as? [[String: Any]])?.first, let items,
let identityRef = item[kSecImportItemIdentity as String] as CFTypeRef?, CFArrayGetCount(items) > 0,
CFGetTypeID(identityRef) == SecIdentityGetTypeID() { let itemPtr = CFArrayGetValueAtIndex(items, 0) else {
return (identityRef as! SecIdentity) printError(title: "Certificate", msg: "Failed to import client identity from .p12 (status: \(status))")
} else {
print("Erro ao importar a identidade do certificado: \(status)")
return nil 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<CFDictionary>.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<SecIdentity>.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. /// Checks if the provided data is a valid PKCS#12 (P12) certificate with the given password.

View File

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