[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
|
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 {
|
|
||||||
|
/// Client-certificate data (`.p12`) for mutual-TLS, if configured.
|
||||||
private static var certData: Data?
|
private var certData: Data?
|
||||||
private static var certPassword: String?
|
/// 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 {
|
||||||
description: LCEssentials.DEFAULT_ERROR_MSG,
|
NSError.createErrorWith(code: LCEssentials.DEFAULT_ERROR_CODE,
|
||||||
reasonForError: LCEssentials.DEFAULT_ERROR_MSG)
|
description: 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,33 +289,63 @@ 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 let certData: Data?
|
||||||
private var certData: Data?
|
private let certPass: String?
|
||||||
private var certPass: String?
|
|
||||||
|
|
||||||
/// Initializes a new `URLSessionDelegateHandler` instance.
|
/// Initializes a new `URLSessionDelegateHandler` instance.
|
||||||
///
|
///
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - 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.
|
||||||
///
|
///
|
||||||
/// This method is responsible for providing client certificates for client certificate
|
/// 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
|
// 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.
|
||||||
|
|||||||
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