diff --git a/Package.swift b/Package.swift index b5463bd..ecb7a74 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.3 +// swift-tools-version:5.5 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription diff --git a/Sources/Jetworking/Client/Client.swift b/Sources/Jetworking/Client/Client.swift index fc96a99..6d8b354 100644 --- a/Sources/Jetworking/Client/Client.swift +++ b/Sources/Jetworking/Client/Client.swift @@ -12,8 +12,6 @@ public enum APIError: Error { } public final class Client { - public typealias RequestCompletion = (HTTPURLResponse?, Result) -> Void - // MARK: - Properties private lazy var sessionCache: SessionCache = .init(configuration: configuration) @@ -96,7 +94,61 @@ public final class Client { self.session = session } - // MARK: - Methods + private func checkForValidDownloadURL(_ url: URL) -> Bool { + guard let scheme = URLComponents(string: url.absoluteString)?.scheme else { return false } + + return scheme == "http" || scheme == "https" + } + + private func createRequest( + forHttpMethod httpMethod: HTTPMethod, + and endpoint: Endpoint, + and body: Data? = nil, + andAdditionalHeaderFields additionalHeaderFields: [String: String] + ) throws -> URLRequest { + var request = URLRequest( + url: try URLFactory.makeURL(from: endpoint, withBaseURL: configuration.baseURLProvider.baseURL), + httpMethod: httpMethod, + httpBody: body + ) + + var requestInterceptors: [Interceptor] = configuration.interceptors + + // Extra case: POST-request with empty content + // + // Adds custom interceptor after last interceptor for header fields + // to avoid conflict with other custom interceptor if any. + if body == nil && httpMethod == .POST { + let targetIndex = requestInterceptors.lastIndex { $0 is HeaderFieldsInterceptor } + let indexToInsert = targetIndex.flatMap { requestInterceptors.index(after: $0) } + requestInterceptors.insert( + EmptyContentHeaderFieldsInterceptor(), + at: indexToInsert ?? requestInterceptors.endIndex + ) + } + + // Append additional header fields. + additionalHeaderFields.forEach { key, value in + request.addValue(value, forHTTPHeaderField: key) + } + + return requestInterceptors.reduce(request) { request, interceptor in + return interceptor.intercept(request) + } + } + + private func enqueue(_ completion: @escaping @autoclosure () -> Void) { + configuration.responseQueue.async { + completion() + } + } +} + +// MARK: - completion API + +extension Client { + public typealias RequestCompletion = (HTTPURLResponse?, Result) -> Void + @discardableResult public func get( endpoint: Endpoint, @@ -380,57 +432,184 @@ public final class Client { } return task } - - private func checkForValidDownloadURL(_ url: URL) -> Bool { - guard let scheme = URLComponents(string: url.absoluteString)?.scheme else { return false } +} - return scheme == "http" || scheme == "https" +// MARK: - async / await API + +extension Client { + public typealias RequestResult = (HTTPURLResponse?, Result) + + @available(iOS 13.0, macOS 10.15.0, *) + public func get( + endpoint: Endpoint, + andAdditionalHeaderFields additionalHeaderFields: [String: String] = [:] + ) async -> RequestResult { + do { + let request: URLRequest = try createRequest( + forHttpMethod: .GET, + and: endpoint, + andAdditionalHeaderFields: additionalHeaderFields + ) + + let (data, urlResponse) = try await requestExecuter.send(request: request, delegate: nil) + return await responseHandler.handleDecodableResponse( + data: data, + urlResponse: urlResponse, + endpoint: endpoint + ) + } catch { + return (nil, .failure(error)) + } } - private func createRequest( - forHttpMethod httpMethod: HTTPMethod, - and endpoint: Endpoint, - and body: Data? = nil, - andAdditionalHeaderFields additionalHeaderFields: [String: String] - ) throws -> URLRequest { - var request = URLRequest( - url: try URLFactory.makeURL(from: endpoint, withBaseURL: configuration.baseURLProvider.baseURL), - httpMethod: httpMethod, - httpBody: body - ) + @available(iOS 13.0, macOS 10.15.0, *) + @discardableResult + public func post( + endpoint: Endpoint, + body: BodyType, + andAdditionalHeaderFields additionalHeaderFields: [String: String] = [:] + ) async -> RequestResult { + do { + let encoder: Encoder = endpoint.encoder ?? configuration.encoder + let bodyData: Data = try encoder.encode(body) + let request: URLRequest = try createRequest( + forHttpMethod: .POST, + and: endpoint, + and: bodyData, + andAdditionalHeaderFields: additionalHeaderFields + ) - var requestInterceptors: [Interceptor] = configuration.interceptors + let (data, urlResponse) = try await requestExecuter.send(request: request, delegate: nil) + return await responseHandler.handleDecodableResponse( + data: data, + urlResponse: urlResponse, + endpoint: endpoint + ) + } catch { + return (nil, .failure(error)) + } + } - // Extra case: POST-request with empty content - // - // Adds custom interceptor after last interceptor for header fields - // to avoid conflict with other custom interceptor if any. - if body == nil && httpMethod == .POST { - let targetIndex = requestInterceptors.lastIndex { $0 is HeaderFieldsInterceptor } - let indexToInsert = targetIndex.flatMap { requestInterceptors.index(after: $0) } - requestInterceptors.insert( - EmptyContentHeaderFieldsInterceptor(), - at: indexToInsert ?? requestInterceptors.endIndex + @available(iOS 13.0, macOS 10.15.0, *) + @discardableResult + public func post( + endpoint: Endpoint, + body: ExpressibleByNilLiteral? = nil, + andAdditionalHeaderFields additionalHeaderFields: [String: String] = [:] + ) async -> RequestResult { + do { + let request: URLRequest = try createRequest( + forHttpMethod: .POST, + and: endpoint, + andAdditionalHeaderFields: additionalHeaderFields + ) + + let (data, urlResponse) = try await requestExecuter.send(request: request, delegate: nil) + return await responseHandler.handleDecodableResponse( + data: data, + urlResponse: urlResponse, + endpoint: endpoint ) + } catch { + return (nil, .failure(error)) } + } - // Append additional header fields. - additionalHeaderFields.forEach { key, value in - request.addValue(value, forHTTPHeaderField: key) + @available(iOS 13.0, macOS 10.15.0, *) + @discardableResult + public func put( + endpoint: Endpoint, + body: BodyType, + andAdditionalHeaderFields additionalHeaderFields: [String: String] = [:] + ) async -> RequestResult { + do { + let encoder: Encoder = endpoint.encoder ?? configuration.encoder + let bodyData: Data = try encoder.encode(body) + let request: URLRequest = try createRequest( + forHttpMethod: .PUT, + and: endpoint, + and: bodyData, + andAdditionalHeaderFields: additionalHeaderFields + ) + + let (data, urlResponse) = try await requestExecuter.send(request: request, delegate: nil) + return await responseHandler.handleDecodableResponse( + data: data, + urlResponse: urlResponse, + endpoint: endpoint + ) + } catch { + return (nil, .failure(error)) } + } - return requestInterceptors.reduce(request) { request, interceptor in - return interceptor.intercept(request) + @available(iOS 13.0, macOS 10.15.0, *) + @discardableResult + public func patch( + endpoint: Endpoint, + body: BodyType, + andAdditionalHeaderFields additionalHeaderFields: [String: String] = [:] + ) async -> RequestResult { + do { + let encoder: Encoder = endpoint.encoder ?? configuration.encoder + let bodyData: Data = try encoder.encode(body) + let request: URLRequest = try createRequest( + forHttpMethod: .PATCH, + and: endpoint, + and: bodyData, + andAdditionalHeaderFields: additionalHeaderFields + ) + + let (data, urlResponse) = try await requestExecuter.send(request: request, delegate: nil) + return await responseHandler.handleDecodableResponse( + data: data, + urlResponse: urlResponse, + endpoint: endpoint + ) + } catch { + return (nil, .failure(error)) } } - private func enqueue(_ completion: @escaping @autoclosure () -> Void) { - configuration.responseQueue.async { - completion() + @available(iOS 13.0, macOS 10.15.0, *) + @discardableResult + public func delete( + endpoint: Endpoint, + parameter: [String: Any] = [:], + andAdditionalHeaderFields additionalHeaderFields: [String: String] = [:] + ) async -> RequestResult { + do { + let request: URLRequest = try createRequest( + forHttpMethod: .DELETE, + and: endpoint, + andAdditionalHeaderFields: additionalHeaderFields + ) + + let (data, urlResponse) = try await requestExecuter.send(request: request, delegate: nil) + return await responseHandler.handleDecodableResponse( + data: data, + urlResponse: urlResponse, + endpoint: endpoint + ) + } catch { + return (nil, .failure(error)) + } + } + + @available(iOS 13.0, macOS 10.15.0, *) + @discardableResult + public func send(request: URLRequest) async -> (Data?, URLResponse?, Error?) { + do { + let (data, urlResponse) = try await requestExecuter.send(request: request, delegate: nil) + return (data, urlResponse, nil) + } catch { + return (nil, nil, error) } } } +// MARK: - DownloadExecuterDelegate + extension Client: DownloadExecuterDelegate { public func downloadExecuter( _ downloadTask: URLSessionDownloadTask, @@ -491,6 +670,8 @@ extension Client: DownloadExecuterDelegate { } } +// MARK: - UploadExecuterDelegate + extension Client: UploadExecuterDelegate { public func uploadExecuter( _ uploadTask: URLSessionUploadTask, @@ -501,13 +682,13 @@ extension Client: UploadExecuterDelegate { guard let progressHandler = executingUploads[uploadTask.identifier]?.progressHandler else { return } enqueue(progressHandler(totalBytesSent, totalBytesExpectedToSend)) } - + public func uploadExecuter(didFinishWith uploadTask: URLSessionUploadTask) { // TODO handle response before calling the completion guard let completionHandler = executingUploads[uploadTask.identifier]?.completionHandler else { return } enqueue(completionHandler(uploadTask.response, uploadTask.error)) } - + public func uploadExecuter(_ uploadTask: URLSessionUploadTask, didCompleteWithError error: Error?) { // TODO handle response before calling the completion guard let completionHandler = executingUploads[uploadTask.identifier]?.completionHandler else { return } diff --git a/Sources/Jetworking/Client/Executor/RequestExecuter/AsyncRequestExecuter/AsyncRequestExecuter.swift b/Sources/Jetworking/Client/Executor/RequestExecuter/AsyncRequestExecuter/AsyncRequestExecuter.swift index 0ae2383..84ed1d7 100644 --- a/Sources/Jetworking/Client/Executor/RequestExecuter/AsyncRequestExecuter/AsyncRequestExecuter.swift +++ b/Sources/Jetworking/Client/Executor/RequestExecuter/AsyncRequestExecuter/AsyncRequestExecuter.swift @@ -13,4 +13,13 @@ final class AsyncRequestExecuter: RequestExecuter { return dataTask } + + @available(iOS 13.0, macOS 10.15.0, *) + func send(request: URLRequest, delegate: URLSessionTaskDelegate? = nil) async throws -> (Data?, URLResponse?) { + if #available(iOS 15.0, macOS 12.0, *) { + return try await session.data(for: request, delegate: delegate) + } else { + return try await session.data(for: request) + } + } } diff --git a/Sources/Jetworking/Client/Executor/RequestExecuter/RequestExecuter.swift b/Sources/Jetworking/Client/Executor/RequestExecuter/RequestExecuter.swift index 830aba1..5758201 100644 --- a/Sources/Jetworking/Client/Executor/RequestExecuter/RequestExecuter.swift +++ b/Sources/Jetworking/Client/Executor/RequestExecuter/RequestExecuter.swift @@ -28,4 +28,7 @@ public protocol RequestExecuter { * The request to be able to cancel it if necessary. */ func send(request: URLRequest, _ completion: @escaping ((Data?, URLResponse?, Error?) -> Void)) -> CancellableRequest? + + @available(iOS 13.0, macOS 10.15.0, *) + func send(request: URLRequest, delegate: URLSessionTaskDelegate?) async throws -> (Data?, URLResponse?) } diff --git a/Sources/Jetworking/Client/Executor/RequestExecuter/SyncRequestExecuter/SyncRequestExecuter.swift b/Sources/Jetworking/Client/Executor/RequestExecuter/SyncRequestExecuter/SyncRequestExecuter.swift index b2c6362..b5794da 100644 --- a/Sources/Jetworking/Client/Executor/RequestExecuter/SyncRequestExecuter/SyncRequestExecuter.swift +++ b/Sources/Jetworking/Client/Executor/RequestExecuter/SyncRequestExecuter/SyncRequestExecuter.swift @@ -19,4 +19,9 @@ final class SyncRequestExecuter: RequestExecuter { return operation } + + @available(iOS 13.0, macOS 10.15.0, *) + func send(request: URLRequest, delegate: URLSessionTaskDelegate?) async throws -> (Data?, URLResponse?) { + return (nil, nil) + } } diff --git a/Sources/Jetworking/Client/ResponseHandler.swift b/Sources/Jetworking/Client/ResponseHandler.swift index 0cd5aa1..815dc6b 100644 --- a/Sources/Jetworking/Client/ResponseHandler.swift +++ b/Sources/Jetworking/Client/ResponseHandler.swift @@ -29,6 +29,15 @@ final class ResponseHandler { evaluate(data: data, urlResponse: urlResponse, error: error, endpoint: endpoint, completionWrapper: makeDecodableCompletionWrapper, completion: completion) } + @available(iOS 13.0, macOS 10.15.0, *) + func handleDecodableResponse( + data: Data?, + urlResponse: URLResponse?, + endpoint: Endpoint? = nil + ) async -> (HTTPURLResponse?, Result) { + await evaluate(data: data, urlResponse: urlResponse, endpoint: endpoint) + } + private func makeVoidCompletionWrapper( currentURLResponse: HTTPURLResponse?, data: Data?, @@ -121,6 +130,54 @@ final class ResponseHandler { } } + @available(iOS 13.0, macOS 10.15.0, *) + private func evaluate( + data: Data?, + urlResponse: URLResponse?, + endpoint: Endpoint? = nil + ) async -> (HTTPURLResponse?, Result) { + let interceptedResponse = configuration.interceptors.reduce(urlResponse) { response, component in + return component.intercept(response: response, data: data, error: nil) + } + + guard let currentURLResponse = interceptedResponse as? HTTPURLResponse else { + return (nil, .failure(APIError.responseMissing)) + } + + switch HTTPStatusCodeType(statusCode: currentURLResponse.statusCode) { + case .successful: + guard let data = data else { return (nil, .failure(APIError.missingResponseBody)) } + let decoder = endpoint?.decoder ?? configuration.decoder + do { + let responseType = try decoder.decode(ResponseType.self, from: data) + return (currentURLResponse, .success(responseType)) + } catch { + return (nil, .failure(APIError.decodingError(error))) + } + + case .serverError: + let apiError: APIError = APIError.serverError( + statusCode: currentURLResponse.statusCode, + error: nil, + body: data + ) + + return (currentURLResponse, .failure(apiError)) + + case .clientError: + let apiError: APIError = APIError.clientError( + statusCode: currentURLResponse.statusCode, + error: nil, + body: data + ) + + return (currentURLResponse, .failure(apiError)) + + default: + return (nil, .failure(APIError.unexpectedError)) + } + } + private func enqueue(_ completion: @escaping @autoclosure () -> Void, inDispatchQueue queue: DispatchQueue) { queue.async { completion() diff --git a/Sources/Jetworking/Extensions/Future+Extension.swift b/Sources/Jetworking/Extensions/Future+Extension.swift new file mode 100644 index 0000000..8f1d0e0 --- /dev/null +++ b/Sources/Jetworking/Extensions/Future+Extension.swift @@ -0,0 +1,18 @@ +import Combine +import Foundation + +@available(iOS 15.0, macOS 12.0, *) +extension Future where Failure == Error { + convenience init(operation: @escaping () async throws -> Output) { + self.init { promise in + Task { + do { + let output = try await operation() + promise(.success(output)) + } catch { + promise(.failure(error)) + } + } + } + } +} diff --git a/Sources/Jetworking/Extensions/Task+Extension.swift b/Sources/Jetworking/Extensions/Task+Extension.swift new file mode 100644 index 0000000..2549196 --- /dev/null +++ b/Sources/Jetworking/Extensions/Task+Extension.swift @@ -0,0 +1,29 @@ +import Foundation + +@available(iOS 15.0, macOS 12.0, *) +extension Task where Failure == Error { + @discardableResult + static func retrying( + priority: TaskPriority? = nil, + maxRetryCount: Int = 3, + retryDelay: TimeInterval = 1, + operation: @Sendable @escaping () async throws -> Success + ) -> Task { + Task(priority: priority) { + for _ in 0...sleep(nanoseconds: delay) + + continue + } + } + + try Task.checkCancellation() + return try await operation() + } + } +} diff --git a/Sources/Jetworking/Extensions/URLSession+Extension.swift b/Sources/Jetworking/Extensions/URLSession+Extension.swift new file mode 100644 index 0000000..644389e --- /dev/null +++ b/Sources/Jetworking/Extensions/URLSession+Extension.swift @@ -0,0 +1,35 @@ +import Foundation + +@available(iOS, deprecated: 15.0, message: "Use the built-in API instead") +public extension URLSession { + /// Start a data task with a `URLRequest` using async/await. + /// - parameter request: The `URLRequest` that the data task should perform. + /// - returns: A tuple containing the binary `Data` that was downloaded, + /// as well as a `URLResponse` representing the server's response. + /// - throws: Any error encountered while performing the data task. + @available(iOS 13.0, macOS 10.15.0, *) + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + var dataTask: URLSessionDataTask? + let onCancel = { dataTask?.cancel() } + + return try await withTaskCancellationHandler( + handler: { + onCancel() + }, + operation: { + try await withCheckedThrowingContinuation { continuation in + dataTask = self.dataTask(with: request) { data, response, error in + guard let data = data, let response = response else { + let error = error ?? URLError(.badServerResponse) + return continuation.resume(throwing: error) + } + + continuation.resume(returning: (data, response)) + } + + dataTask?.resume() + } + } + ) + } +} diff --git a/Tests/JetworkingTests/Mocks/MockExecuter.swift b/Tests/JetworkingTests/Mocks/MockExecuter.swift index 63d0836..86c1c16 100644 --- a/Tests/JetworkingTests/Mocks/MockExecuter.swift +++ b/Tests/JetworkingTests/Mocks/MockExecuter.swift @@ -54,6 +54,11 @@ final class MockExecuter: RequestExecuter { } } + @available(iOS 13.0, macOS 10.15.0, *) + func send(request: URLRequest, delegate: URLSessionTaskDelegate?) async throws -> (Data?, URLResponse?) { + return (nil, nil) + } + private func execute( request: URLRequest, completion: @escaping ((Data?, URLResponse?, Error?) -> Void)