[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:
@@ -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 {
|
||||
public actor API {
|
||||
|
||||
private static var certData: Data?
|
||||
private static var certPassword: String?
|
||||
/// 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,
|
||||
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",
|
||||
nonisolated let defaultHeaders: [String: String] = ["Accept": "application/json",
|
||||
"Content-Type": "application/json; charset=UTF-8",
|
||||
"Accept-Encoding": "gzip"]
|
||||
|
||||
/// The shared singleton instance of the `API` struct.
|
||||
/// 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,21 +289,51 @@ 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 final class URLSessionDelegateHandler: NSObject, URLSessionDelegate, @unchecked Sendable {
|
||||
|
||||
private var certData: Data?
|
||||
private var certPass: String?
|
||||
private let certData: Data?
|
||||
private let certPass: String?
|
||||
|
||||
/// Initializes a new `URLSessionDelegateHandler` instance.
|
||||
///
|
||||
@@ -314,9 +341,9 @@ private class URLSessionDelegateHandler: NSObject, URLSessionDelegate {
|
||||
/// - 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.
|
||||
@@ -363,16 +390,29 @@ 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.
|
||||
|
||||
26
Tests/LCEssentialsTests/APIActorTests.swift
Normal file
26
Tests/LCEssentialsTests/APIActorTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user