diff --git a/APIKit.xcodeproj/project.pbxproj b/APIKit.xcodeproj/project.pbxproj index e460ab57..9178063a 100644 --- a/APIKit.xcodeproj/project.pbxproj +++ b/APIKit.xcodeproj/project.pbxproj @@ -48,6 +48,8 @@ 7F7048F31D9D8A1F003C99F6 /* URLEncodedSerialization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F7048F21D9D8A1F003C99F6 /* URLEncodedSerialization.swift */; }; 7FA1690D1D9D8C80006C982B /* HTTPStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FA1690C1D9D8C80006C982B /* HTTPStub.swift */; }; C5725F4B28D8C36500810D7C /* Concurrency.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5725F4A28D8C36500810D7C /* Concurrency.swift */; }; + C5AC78842C535379008EDBDC /* UncheckedSendableBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5AC78832C535379008EDBDC /* UncheckedSendableBox.swift */; }; + C5AC78862C537710008EDBDC /* ErrorObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5AC78852C537710008EDBDC /* ErrorObject.swift */; }; C5B144D828D8D7DC00E30ECD /* ConcurrencyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5B144D728D8D7DC00E30ECD /* ConcurrencyTests.swift */; }; C5FF1DC128A80FFD0059573D /* test.json in Resources */ = {isa = PBXBuildFile; fileRef = C5FF1DC028A80FFD0059573D /* test.json */; }; ECA831481DE4DDBF004EB1B5 /* ProtobufDataParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECA831471DE4DDBF004EB1B5 /* ProtobufDataParser.swift */; }; @@ -131,6 +133,8 @@ 7F8ECDFD1B6A799E00234E04 /* Demo.playground */ = {isa = PBXFileReference; lastKnownFileType = file.playground; path = Demo.playground; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; 7FA1690C1D9D8C80006C982B /* HTTPStub.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HTTPStub.swift; sourceTree = ""; }; C5725F4A28D8C36500810D7C /* Concurrency.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Concurrency.swift; sourceTree = ""; }; + C5AC78832C535379008EDBDC /* UncheckedSendableBox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UncheckedSendableBox.swift; sourceTree = ""; }; + C5AC78852C537710008EDBDC /* ErrorObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorObject.swift; sourceTree = ""; }; C5B144D728D8D7DC00E30ECD /* ConcurrencyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConcurrencyTests.swift; sourceTree = ""; }; C5FF1DC028A80FFD0059573D /* test.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = test.json; sourceTree = ""; }; ECA831471DE4DDBF004EB1B5 /* ProtobufDataParser.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ProtobufDataParser.swift; path = Sources/APIKit/DataParser/ProtobufDataParser.swift; sourceTree = SOURCE_ROOT; }; @@ -315,6 +319,7 @@ 7F7048CA1D9D89BE003C99F6 /* Request.swift */, 7F7048CB1D9D89BE003C99F6 /* Session.swift */, 7F7048CC1D9D89BE003C99F6 /* Unavailable.swift */, + C5AC78832C535379008EDBDC /* UncheckedSendableBox.swift */, C5725F4928D8C36500810D7C /* Concurrency */, 0969AE0D259DEC3C00C498AF /* Combine */, 7F85FB8B1C9D317300CEE132 /* SessionAdapter */, @@ -352,6 +357,7 @@ 7F7048EE1D9D8A12003C99F6 /* SessionTaskError.swift */, 7F7048EC1D9D8A12003C99F6 /* RequestError.swift */, 7F7048ED1D9D8A12003C99F6 /* ResponseError.swift */, + C5AC78852C537710008EDBDC /* ErrorObject.swift */, ); name = Error; path = APIKit/Error; @@ -514,8 +520,10 @@ 7F7048E31D9D89FB003C99F6 /* MultipartFormDataBodyParameters.swift in Sources */, 7F7048F01D9D8A12003C99F6 /* ResponseError.swift in Sources */, 7F7048EA1D9D8A08003C99F6 /* JSONDataParser.swift in Sources */, + C5AC78862C537710008EDBDC /* ErrorObject.swift in Sources */, 7F7048D21D9D89BE003C99F6 /* Session.swift in Sources */, 7F7048E01D9D89FB003C99F6 /* Data+InputStream.swift in Sources */, + C5AC78842C535379008EDBDC /* UncheckedSendableBox.swift in Sources */, 7F7048DF1D9D89FB003C99F6 /* BodyParameters.swift in Sources */, 7F7048E21D9D89FB003C99F6 /* JSONBodyParameters.swift in Sources */, C5725F4B28D8C36500810D7C /* Concurrency.swift in Sources */, @@ -578,9 +586,14 @@ buildSettings = { CLANG_ENABLE_MODULES = YES; DEFINES_MODULE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 13.0; SWIFT_OBJC_BRIDGING_HEADER = ""; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_STRICT_CONCURRENCY = complete; + TVOS_DEPLOYMENT_TARGET = 16.0; + WATCHOS_DEPLOYMENT_TARGET = 9.0; }; name = Debug; }; @@ -590,9 +603,14 @@ buildSettings = { CLANG_ENABLE_MODULES = YES; DEFINES_MODULE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 13.0; SWIFT_OBJC_BRIDGING_HEADER = ""; SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; + SWIFT_STRICT_CONCURRENCY = complete; + TVOS_DEPLOYMENT_TARGET = 16.0; + WATCHOS_DEPLOYMENT_TARGET = 9.0; }; name = Release; }; @@ -601,6 +619,9 @@ baseConfigurationReference = 141F12401C1C9EA30026D415 /* Tests.xcconfig */; buildSettings = { INFOPLIST_FILE = Tests/APIKitTests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; + TVOS_DEPLOYMENT_TARGET = 16.0; }; name = Debug; }; @@ -609,7 +630,10 @@ baseConfigurationReference = 141F12401C1C9EA30026D415 /* Tests.xcconfig */; buildSettings = { INFOPLIST_FILE = Tests/APIKitTests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; + TVOS_DEPLOYMENT_TARGET = 16.0; }; name = Release; }; diff --git a/Package.swift b/Package.swift index e55d20b6..69695731 100644 --- a/Package.swift +++ b/Package.swift @@ -1,10 +1,10 @@ -// swift-tools-version:5.3 +// swift-tools-version:5.7 import PackageDescription let package = Package( name: "APIKit", platforms: [ - .macOS(.v10_10), .iOS(.v9), .tvOS(.v9), .watchOS(.v2) + .macOS(.v13), .iOS(.v16), .tvOS(.v16), .watchOS(.v9) ], products: [ .library(name: "APIKit", targets: ["APIKit"]), diff --git a/Sources/APIKit/BodyParameters/Data+InputStream.swift b/Sources/APIKit/BodyParameters/Data+InputStream.swift index cb186d79..70b27843 100644 --- a/Sources/APIKit/BodyParameters/Data+InputStream.swift +++ b/Sources/APIKit/BodyParameters/Data+InputStream.swift @@ -2,7 +2,7 @@ import Foundation enum InputStreamError: Error { case invalidDataCapacity(Int) - case unreadableStream(InputStream) + case unreadableStream(Error?) } extension Data { @@ -22,7 +22,7 @@ extension Data { data.append(buffer, count: readSize) case let x where x < 0: - throw InputStreamError.unreadableStream(inputStream) + throw InputStreamError.unreadableStream(inputStream.streamError) default: break diff --git a/Sources/APIKit/BodyParameters/MultipartFormDataBodyParameters.swift b/Sources/APIKit/BodyParameters/MultipartFormDataBodyParameters.swift index d1337978..c4ec2f90 100644 --- a/Sources/APIKit/BodyParameters/MultipartFormDataBodyParameters.swift +++ b/Sources/APIKit/BodyParameters/MultipartFormDataBodyParameters.swift @@ -1,4 +1,5 @@ import Foundation +import UniformTypeIdentifiers #if os(iOS) || os(watchOS) || os(tvOS) import MobileCoreServices @@ -60,7 +61,7 @@ public extension MultipartFormDataBodyParameters { /// Part represents single part of multipart/form-data. struct Part { public enum Error: Swift.Error { - case illegalValue(Any) + case illegalValue(ErrorObject) case illegalFileURL(URL) case cannotGetFileSize(URL) } @@ -76,7 +77,7 @@ public extension MultipartFormDataBodyParameters { /// If `mimeType` or `fileName` are `nil`, the fields will be omitted. public init(value: Any, name: String, mimeType: String? = nil, fileName: String? = nil, encoding: String.Encoding = .utf8) throws { guard let data = String(describing: value).data(using: encoding) else { - throw Error.illegalValue(value) + throw Error.illegalValue(.init(value: value)) } self.inputStream = InputStream(data: data) @@ -111,9 +112,8 @@ public extension MultipartFormDataBodyParameters { throw Error.cannotGetFileSize(fileURL) } - let detectedMimeType = UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, fileURL.pathExtension as CFString, nil) - .map { $0.takeRetainedValue() } - .flatMap { UTTypeCopyPreferredTagWithClass($0, kUTTagClassMIMEType)?.takeRetainedValue() } + let detectedMimeType = UTType(filenameExtension: fileURL.pathExtension) + .flatMap { $0.preferredMIMEType } .map { $0 as String } self.inputStream = inputStream diff --git a/Sources/APIKit/CallbackQueue.swift b/Sources/APIKit/CallbackQueue.swift index 5fb23cd5..361b62d1 100644 --- a/Sources/APIKit/CallbackQueue.swift +++ b/Sources/APIKit/CallbackQueue.swift @@ -1,7 +1,7 @@ import Foundation /// `CallbackQueue` represents queue where `handler` of `Session.send(_:handler:)` runs. -public enum CallbackQueue { +public enum CallbackQueue: Sendable { /// Dispatches callback closure on main queue asynchronously. case main @@ -14,7 +14,7 @@ public enum CallbackQueue { /// Dispatches callback closure on associated dispatch queue. case dispatchQueue(DispatchQueue) - public func execute(closure: @escaping () -> Void) { + public func execute(closure: @escaping @Sendable () -> Void) { switch self { case .main: DispatchQueue.main.async { diff --git a/Sources/APIKit/Combine/Combine.swift b/Sources/APIKit/Combine/Combine.swift index 6e3223c0..23cfd399 100644 --- a/Sources/APIKit/Combine/Combine.swift +++ b/Sources/APIKit/Combine/Combine.swift @@ -28,15 +28,15 @@ public struct SessionTaskPublisher: Publisher { downstream: subscriber)) } - private final class SessionTaskSubscription: Subscription where Request.Response == Downstream.Input, Downstream.Failure == Failure { + private final class SessionTaskSubscription: Subscription where Req.Response == Downstream.Input, Downstream.Failure == Failure { - private let request: Request + private let request: Req private let session: Session private let callbackQueue: CallbackQueue? private var downstream: Downstream? private var task: SessionTask? - init(request: Request, session: Session, callbackQueue: CallbackQueue?, downstream: Downstream) { + init(request: Req, session: Session, callbackQueue: CallbackQueue?, downstream: Downstream) { self.request = request self.session = session self.callbackQueue = callbackQueue @@ -47,13 +47,14 @@ public struct SessionTaskPublisher: Publisher { assert(demand > 0) guard let downstream = self.downstream else { return } self.downstream = nil + let ds = UncheckedSendableBox(value: downstream) task = session.send(request, callbackQueue: callbackQueue) { result in switch result { case .success(let response): - _ = downstream.receive(response) - downstream.receive(completion: .finished) + _ = ds.value.receive(response) + ds.value.receive(completion: .finished) case .failure(let error): - downstream.receive(completion: .failure(error)) + ds.value.receive(completion: .failure(error)) } } } diff --git a/Sources/APIKit/Concurrency/Concurrency.swift b/Sources/APIKit/Concurrency/Concurrency.swift index c0518e35..b12db9ae 100644 --- a/Sources/APIKit/Concurrency/Concurrency.swift +++ b/Sources/APIKit/Concurrency/Concurrency.swift @@ -22,10 +22,10 @@ public extension Session { let cancellationHandler = SessionTaskCancellationHandler() return try await withTaskCancellationHandler(operation: { return try await withCheckedThrowingContinuation { continuation in + let sessionTask = createSessionTask(request, callbackQueue: callbackQueue) { result in + continuation.resume(with: result) + } Task { - let sessionTask = createSessionTask(request, callbackQueue: callbackQueue) { result in - continuation.resume(with: result) - } await cancellationHandler.register(with: sessionTask) if await cancellationHandler.isTaskCancelled { sessionTask?.cancel() diff --git a/Sources/APIKit/Error/ErrorObject.swift b/Sources/APIKit/Error/ErrorObject.swift new file mode 100644 index 00000000..92983f85 --- /dev/null +++ b/Sources/APIKit/Error/ErrorObject.swift @@ -0,0 +1,7 @@ +public final class ErrorObject: @unchecked Sendable { + public let value: Value + + public init(value: Value) { + self.value = value + } +} diff --git a/Sources/APIKit/Error/ResponseError.swift b/Sources/APIKit/Error/ResponseError.swift index 50efdecd..b59a8233 100644 --- a/Sources/APIKit/Error/ResponseError.swift +++ b/Sources/APIKit/Error/ResponseError.swift @@ -11,5 +11,5 @@ public enum ResponseError: Error { case unacceptableStatusCode(Int) /// Indicates `Any` that represents the response is unexpected. - case unexpectedObject(Any) + case unexpectedObject(ErrorObject) } diff --git a/Sources/APIKit/HTTPMethod.swift b/Sources/APIKit/HTTPMethod.swift index 6a29f58e..d1808a74 100644 --- a/Sources/APIKit/HTTPMethod.swift +++ b/Sources/APIKit/HTTPMethod.swift @@ -1,7 +1,7 @@ import Foundation /// `HTTPMethod` represents HTTP methods. -public enum HTTPMethod: String { +public enum HTTPMethod: String, Sendable { case get = "GET" case post = "POST" case put = "PUT" diff --git a/Sources/APIKit/Request.swift b/Sources/APIKit/Request.swift index ea6c5dae..76624d70 100644 --- a/Sources/APIKit/Request.swift +++ b/Sources/APIKit/Request.swift @@ -7,7 +7,7 @@ import Foundation /// - `var method: HTTPMethod` /// - `var path: String` /// - `func response(from object: Any, urlResponse: HTTPURLResponse) throws -> Response` -public protocol Request { +public protocol Request: Sendable { /// The response type associated with the request type. associatedtype Response @@ -23,12 +23,12 @@ public protocol Request { /// The convenience property for `queryParameters` and `bodyParameters`. If the implementation of /// `queryParameters` and `bodyParameters` are not provided, the values for them will be computed /// from this property depending on `method`. - var parameters: Any? { get } + var parameters: Sendable? { get } /// The actual parameters for the URL query. The values of this property will be escaped using `URLEncodedSerialization`. /// If this property is not implemented and `method.prefersQueryParameter` is `true`, the value of this property /// will be computed from `parameters`. - var queryParameters: [String: Any]? { get } + var queryParameters: [String: Sendable]? { get } /// The actual parameters for the HTTP body. If this property is not implemented and `method.prefersQueryParameter` is `false`, /// the value of this property will be computed from `parameters` using `JSONBodyParameters`. @@ -61,12 +61,12 @@ public protocol Request { } public extension Request { - var parameters: Any? { + var parameters: Sendable? { return nil } - var queryParameters: [String: Any]? { - guard let parameters = parameters as? [String: Any], method.prefersQueryParameters else { + var queryParameters: [String: Sendable]? { + guard let parameters = parameters as? [String: Sendable], method.prefersQueryParameters else { return nil } diff --git a/Sources/APIKit/Serializations/URLEncodedSerialization.swift b/Sources/APIKit/Serializations/URLEncodedSerialization.swift index 1c13425d..a8891d17 100644 --- a/Sources/APIKit/Serializations/URLEncodedSerialization.swift +++ b/Sources/APIKit/Serializations/URLEncodedSerialization.swift @@ -47,7 +47,7 @@ public final class URLEncodedSerialization { public enum Error: Swift.Error { case cannotGetStringFromData(Data, String.Encoding) case cannotGetDataFromString(String, String.Encoding) - case cannotCastObjectToDictionary(Any) + case cannotCastObjectToDictionary(ErrorObject) case invalidFormatString(String) } @@ -76,7 +76,7 @@ public final class URLEncodedSerialization { /// - Throws: URLEncodedSerialization.Error public static func data(from object: Any, encoding: String.Encoding) throws -> Data { guard let dictionary = object as? [String: Any] else { - throw Error.cannotCastObjectToDictionary(object) + throw Error.cannotCastObjectToDictionary(.init(value: object)) } let string = self.string(from: dictionary) diff --git a/Sources/APIKit/Session.swift b/Sources/APIKit/Session.swift index 85fb9615..ab58a645 100644 --- a/Sources/APIKit/Session.swift +++ b/Sources/APIKit/Session.swift @@ -1,6 +1,6 @@ import Foundation -private var taskRequestKey = 0 +private let taskRequestKey = UncheckedSendableBox(value: 0) /// `Session` manages tasks for HTTP/HTTPS requests. open class Session { @@ -19,15 +19,15 @@ open class Session { } // Shared session for class methods - private static let privateShared: Session = { + private static let privateShared: UncheckedSendableBox = { let configuration = URLSessionConfiguration.default let adapter = URLSessionAdapter(configuration: configuration) - return Session(adapter: adapter) + return UncheckedSendableBox(value: Session(adapter: adapter)) }() /// The shared `Session` instance for class methods, `Session.send(_:handler:)` and `Session.cancelRequests(with:passingTest:)`. open class var shared: Session { - return privateShared + return privateShared.value } /// Calls `send(_:callbackQueue:handler:)` of `Session.shared`. @@ -36,12 +36,12 @@ open class Session { /// - parameter handler: The closure that receives result of the request. /// - returns: The new session task. @discardableResult - open class func send(_ request: Request, callbackQueue: CallbackQueue? = nil, handler: @escaping (Result) -> Void = { _ in }) -> SessionTask? { + open class func send(_ request: Request, callbackQueue: CallbackQueue? = nil, handler: @escaping @Sendable (Result) -> Void = { _ in }) -> SessionTask? { return shared.send(request, callbackQueue: callbackQueue, handler: handler) } /// Calls `cancelRequests(with:passingTest:)` of `Session.shared`. - open class func cancelRequests(with requestType: Request.Type, passingTest test: @escaping (Request) -> Bool = { _ in true }) { + open class func cancelRequests(with requestType: Request.Type, passingTest test: @escaping @Sendable (Request) -> Bool = { _ in true }) { shared.cancelRequests(with: requestType, passingTest: test) } @@ -54,7 +54,7 @@ open class Session { /// - parameter handler: The closure that receives result of the request. /// - returns: The new session task. @discardableResult - open func send(_ request: Request, callbackQueue: CallbackQueue? = nil, handler: @escaping (Result) -> Void = { _ in }) -> SessionTask? { + open func send(_ request: Request, callbackQueue: CallbackQueue? = nil, handler: @escaping @Sendable (Result) -> Void = { _ in }) -> SessionTask? { let task = createSessionTask(request, callbackQueue: callbackQueue, handler: handler) task?.resume() return task @@ -63,11 +63,12 @@ open class Session { /// Cancels requests that passes the test. /// - parameter requestType: The request type to cancel. /// - parameter test: The test closure that determines if a request should be cancelled or not. - open func cancelRequests(with requestType: Request.Type, passingTest test: @escaping (Request) -> Bool = { _ in true }) { - adapter.getTasks { [weak self] tasks in + open func cancelRequests(with requestType: Request.Type, passingTest test: @escaping @Sendable (Request) -> Bool = { _ in true }) { + let selfBox = UncheckedSendableWeakBox(value: self) + adapter.getTasks { tasks in tasks .filter { task in - if let request = self?.requestForTask(task) as Request? { + if let request = selfBox.value?.requestForTask(task) as Request? { return test(request) } else { return false @@ -77,7 +78,7 @@ open class Session { } } - internal func createSessionTask(_ request: Request, callbackQueue: CallbackQueue?, handler: @escaping (Result) -> Void) -> SessionTask? { + internal func createSessionTask(_ request: Request, callbackQueue: CallbackQueue?, handler: @escaping @Sendable (Result) -> Void) -> SessionTask? { let callbackQueue = callbackQueue ?? self.callbackQueue let urlRequest: URLRequest do { @@ -90,25 +91,25 @@ open class Session { } let task = adapter.createTask(with: urlRequest) { data, urlResponse, error in - let result: Result + let result: UncheckedSendableBox> switch (data, urlResponse, error) { case (_, _, let error?): - result = .failure(.connectionError(error)) + result = .init(value: .failure(.connectionError(error))) case (let data?, let urlResponse as HTTPURLResponse, _): do { - result = .success(try request.parse(data: data as Data, urlResponse: urlResponse)) + result = .init(value: .success(try request.parse(data: data as Data, urlResponse: urlResponse))) } catch { - result = .failure(.responseError(error)) + result = .init(value: .failure(.responseError(error))) } default: - result = .failure(.responseError(ResponseError.nonHTTPURLResponse(urlResponse))) + result = .init(value: .failure(.responseError(ResponseError.nonHTTPURLResponse(urlResponse)))) } callbackQueue.execute { - handler(result) + handler(result.value) } } @@ -118,10 +119,10 @@ open class Session { } private func setRequest(_ request: Request, forTask task: SessionTask) { - objc_setAssociatedObject(task, &taskRequestKey, request, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + objc_setAssociatedObject(task, &taskRequestKey.value, request, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } private func requestForTask(_ task: SessionTask) -> Request? { - return objc_getAssociatedObject(task, &taskRequestKey) as? Request + return objc_getAssociatedObject(task, &taskRequestKey.value) as? Request } } diff --git a/Sources/APIKit/SessionAdapter/SessionAdapter.swift b/Sources/APIKit/SessionAdapter/SessionAdapter.swift index aa71ac5d..157c53f8 100644 --- a/Sources/APIKit/SessionAdapter/SessionAdapter.swift +++ b/Sources/APIKit/SessionAdapter/SessionAdapter.swift @@ -1,7 +1,7 @@ import Foundation /// `SessionTask` protocol represents a task for a request. -public protocol SessionTask: AnyObject { +public protocol SessionTask: AnyObject, Sendable { func resume() func cancel() } @@ -11,8 +11,8 @@ public protocol SessionTask: AnyObject { /// with `Session`. public protocol SessionAdapter { /// Returns instance that conforms to `SessionTask`. `handler` must be called after success or failure. - func createTask(with URLRequest: URLRequest, handler: @escaping (Data?, URLResponse?, Error?) -> Void) -> SessionTask + func createTask(with URLRequest: URLRequest, handler: @escaping @Sendable (Data?, URLResponse?, Error?) -> Void) -> SessionTask /// Collects tasks from backend networking stack. `handler` must be called after collecting. - func getTasks(with handler: @escaping ([SessionTask]) -> Void) + func getTasks(with handler: @escaping @Sendable ([SessionTask]) -> Void) } diff --git a/Sources/APIKit/SessionAdapter/URLSessionAdapter.swift b/Sources/APIKit/SessionAdapter/URLSessionAdapter.swift index 38ac15ef..7004b34a 100644 --- a/Sources/APIKit/SessionAdapter/URLSessionAdapter.swift +++ b/Sources/APIKit/SessionAdapter/URLSessionAdapter.swift @@ -4,8 +4,8 @@ extension URLSessionTask: SessionTask { } -private var dataTaskResponseBufferKey = 0 -private var taskAssociatedObjectCompletionHandlerKey = 0 +private let dataTaskResponseBufferKey = UncheckedSendableBox(value: 0) +private let taskAssociatedObjectCompletionHandlerKey = UncheckedSendableBox(value: 0) /// `URLSessionAdapter` connects `URLSession` with `Session`. /// @@ -25,7 +25,7 @@ open class URLSessionAdapter: NSObject, SessionAdapter, URLSessionDelegate, URLS } /// Creates `URLSessionDataTask` instance using `dataTaskWithRequest(_:completionHandler:)`. - open func createTask(with URLRequest: URLRequest, handler: @escaping (Data?, URLResponse?, Error?) -> Void) -> SessionTask { + open func createTask(with URLRequest: URLRequest, handler: @escaping @Sendable (Data?, URLResponse?, Error?) -> Void) -> SessionTask { let task = urlSession.dataTask(with: URLRequest) setBuffer(NSMutableData(), forTask: task) @@ -35,7 +35,7 @@ open class URLSessionAdapter: NSObject, SessionAdapter, URLSessionDelegate, URLS } /// Aggregates `URLSessionTask` instances in `URLSession` using `getTasksWithCompletionHandler(_:)`. - open func getTasks(with handler: @escaping ([SessionTask]) -> Void) { + open func getTasks(with handler: @escaping @Sendable ([SessionTask]) -> Void) { urlSession.getTasksWithCompletionHandler { dataTasks, uploadTasks, downloadTasks in let allTasks: [URLSessionTask] = dataTasks + uploadTasks + downloadTasks handler(allTasks) @@ -43,19 +43,19 @@ open class URLSessionAdapter: NSObject, SessionAdapter, URLSessionDelegate, URLS } private func setBuffer(_ buffer: NSMutableData, forTask task: URLSessionTask) { - objc_setAssociatedObject(task, &dataTaskResponseBufferKey, buffer, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + objc_setAssociatedObject(task, &dataTaskResponseBufferKey.value, buffer, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } private func buffer(for task: URLSessionTask) -> NSMutableData? { - return objc_getAssociatedObject(task, &dataTaskResponseBufferKey) as? NSMutableData + return objc_getAssociatedObject(task, &dataTaskResponseBufferKey.value) as? NSMutableData } private func setHandler(_ handler: @escaping (Data?, URLResponse?, Error?) -> Void, forTask task: URLSessionTask) { - objc_setAssociatedObject(task, &taskAssociatedObjectCompletionHandlerKey, handler as Any, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + objc_setAssociatedObject(task, &taskAssociatedObjectCompletionHandlerKey.value, handler as Any, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } private func handler(for task: URLSessionTask) -> ((Data?, URLResponse?, Error?) -> Void)? { - return objc_getAssociatedObject(task, &taskAssociatedObjectCompletionHandlerKey) as? (Data?, URLResponse?, Error?) -> Void + return objc_getAssociatedObject(task, &taskAssociatedObjectCompletionHandlerKey.value) as? (Data?, URLResponse?, Error?) -> Void } // MARK: URLSessionTaskDelegate diff --git a/Sources/APIKit/UncheckedSendableBox.swift b/Sources/APIKit/UncheckedSendableBox.swift new file mode 100644 index 00000000..231fa525 --- /dev/null +++ b/Sources/APIKit/UncheckedSendableBox.swift @@ -0,0 +1,15 @@ +public final class UncheckedSendableBox: @unchecked Sendable { + public var value: Value + + public init(value: Value) { + self.value = value + } +} + +final class UncheckedSendableWeakBox: @unchecked Sendable { + weak var value: Value? + + init(value: Value?) { + self.value = value + } +} diff --git a/Tests/APIKitTests/RequestTests.swift b/Tests/APIKitTests/RequestTests.swift index 88bfa72c..58c00032 100644 --- a/Tests/APIKitTests/RequestTests.swift +++ b/Tests/APIKitTests/RequestTests.swift @@ -29,14 +29,14 @@ class RequestTests: XCTestCase { } func testPOSTJSONRequest() throws { - let parameters: [Any] = [ + let parameters: [Sendable] = [ ["id": "1"], ["id": "2"], ["hello", "yellow"] ] let request = TestRequest(method: .post, parameters: parameters) - XCTAssert((request.parameters as? [Any])?.count == 3) + XCTAssert((request.parameters as? [Sendable])?.count == 3) let urlRequest = try request.buildURLRequest() diff --git a/Tests/APIKitTests/TestComponents/TestRequest.swift b/Tests/APIKitTests/TestComponents/TestRequest.swift index e3bc69aa..37a1c2e2 100644 --- a/Tests/APIKitTests/TestComponents/TestRequest.swift +++ b/Tests/APIKitTests/TestComponents/TestRequest.swift @@ -10,7 +10,7 @@ struct TestRequest: Request { // MARK: Request typealias Response = Any - init(baseURL: String = "https://example.com", path: String = "/", method: HTTPMethod = .get, parameters: Any? = [:], headerFields: [String: String] = [:], interceptURLRequest: @escaping (URLRequest) throws -> URLRequest = { $0 }) { + init(baseURL: String = "https://example.com", path: String = "/", method: HTTPMethod = .get, parameters: Sendable? = [:], headerFields: [String: String] = [:], interceptURLRequest: @escaping @Sendable (URLRequest) throws -> URLRequest = { $0 }) { self.baseURL = URL(string: baseURL)! self.path = path self.method = method @@ -22,9 +22,9 @@ struct TestRequest: Request { let baseURL: URL let method: HTTPMethod let path: String - let parameters: Any? + let parameters: Sendable? let headerFields: [String: String] - let interceptURLRequest: (URLRequest) throws -> URLRequest + let interceptURLRequest: @Sendable (URLRequest) throws -> URLRequest func intercept(urlRequest: URLRequest) throws -> URLRequest { return try interceptURLRequest(urlRequest) diff --git a/Tests/APIKitTests/TestComponents/TestSessionAdapter.swift b/Tests/APIKitTests/TestComponents/TestSessionAdapter.swift index 4813c23c..661c2124 100644 --- a/Tests/APIKitTests/TestComponents/TestSessionAdapter.swift +++ b/Tests/APIKitTests/TestComponents/TestSessionAdapter.swift @@ -39,7 +39,7 @@ class TestSessionAdapter: SessionAdapter { func executeAllTasks() { for task in tasks { - if task.cancelled { + if task.cancelled.value { task.handler(nil, nil, Error.cancelled) } else { task.handler(data, urlResponse, error) @@ -49,7 +49,7 @@ class TestSessionAdapter: SessionAdapter { tasks = [] } - func createTask(with URLRequest: URLRequest, handler: @escaping (Data?, URLResponse?, Swift.Error?) -> Void) -> SessionTask { + func createTask(with URLRequest: URLRequest, handler: @escaping @Sendable (Data?, URLResponse?, Swift.Error?) -> Void) -> SessionTask { let task = TestSessionTask(handler: handler) tasks.append(task) diff --git a/Tests/APIKitTests/TestComponents/TestSessionTask.swift b/Tests/APIKitTests/TestComponents/TestSessionTask.swift index 5bf7927a..44e7f715 100644 --- a/Tests/APIKitTests/TestComponents/TestSessionTask.swift +++ b/Tests/APIKitTests/TestComponents/TestSessionTask.swift @@ -1,12 +1,12 @@ import Foundation import APIKit -class TestSessionTask: SessionTask { - - var handler: (Data?, URLResponse?, Error?) -> Void - var cancelled = false +final class TestSessionTask: SessionTask { - init(handler: @escaping (Data?, URLResponse?, Error?) -> Void) { + let handler: @Sendable (Data?, URLResponse?, Error?) -> Void + let cancelled = UncheckedSendableBox(value: false) + + init(handler: @escaping @Sendable (Data?, URLResponse?, Error?) -> Void) { self.handler = handler } @@ -15,6 +15,6 @@ class TestSessionTask: SessionTask { } func cancel() { - cancelled = true + cancelled.value = true } }