[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
#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.
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<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.

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