diff --git a/Package.swift b/Package.swift index 989f9d6..33861dc 100644 --- a/Package.swift +++ b/Package.swift @@ -7,10 +7,10 @@ let package = Package( name: "Get", platforms: [.iOS(.v13), .macCatalyst(.v13), .macOS(.v10_15), .watchOS(.v6), .tvOS(.v13)], products: [ - .library(name: "Get", targets: ["Get"]), + .library(name: "Get", targets: ["Get"]) ], targets: [ .target(name: "Get"), - .testTarget(name: "GetTests", dependencies: ["Get"], resources: [.process("Resources")]), + .testTarget(name: "GetTests", dependencies: ["Get"], resources: [.process("Resources")]) ] ) diff --git a/Sources/Get/APIClient.swift b/Sources/Get/APIClient.swift index d9e7d1b..859a887 100644 --- a/Sources/Get/APIClient.swift +++ b/Sources/Get/APIClient.swift @@ -32,14 +32,14 @@ public actor APIClient { /// The (optional) URLSession delegate that allows you to monitor the underlying URLSession. public var sessionDelegate: URLSessionDelegate? #endif - + public init(baseURL: URL?, sessionConfiguration: URLSessionConfiguration = .default, delegate: APIClientDelegate? = nil) { self.baseURL = baseURL self.sessionConfiguration = sessionConfiguration self.delegate = delegate } } - + /// Initializes the client with the given parameters. /// /// - parameter baseURL: A base URL. For example, `"https://api.github.com"`. @@ -66,8 +66,8 @@ public actor APIClient { } /// Sends the given request and returns a response with a decoded response value. - public func send(_ request: Request) async throws -> Response { - try await send(request) { data in + public func send(_ request: Request, delegate: URLSessionDataDelegate? = nil) async throws -> Response { + try await send(request, delegate: delegate) { data in if data.isEmpty { return nil } else { @@ -77,8 +77,8 @@ public actor APIClient { } /// Sends the given request and returns a response with a decoded response value. - public func send(_ request: Request) async throws -> Response { - try await send(request, decode) + public func send(_ request: Request, delegate: URLSessionDataDelegate? = nil) async throws -> Response { + try await send(request, delegate: delegate, decode) } private func decode(_ data: Data) async throws -> T { @@ -94,30 +94,30 @@ public actor APIClient { /// Sends the given request. @discardableResult - public func send(_ request: Request) async throws -> Response { - try await send(request) { _ in () } + public func send(_ request: Request, delegate: URLSessionDataDelegate? = nil) async throws -> Response { + try await send(request, delegate: delegate) { _ in () } } - private func send(_ request: Request, _ decode: @escaping (Data) async throws -> T) async throws -> Response { + private func send(_ request: Request, delegate: URLSessionDataDelegate?, _ decode: @escaping (Data) async throws -> T) async throws -> Response { let request = try await makeURLRequest(for: request) - let response = try await send(request) + let response = try await send(request, delegate: delegate) let value = try await decode(response.value) return response.map { _ in value } // Keep metadata } - private func send(_ request: URLRequest) async throws -> Response { + private func send(_ request: URLRequest, delegate: URLSessionDataDelegate?) async throws -> Response { do { - return try await actuallySend(request) + return try await actuallySend(request, delegate: delegate) } catch { - guard try await delegate.shouldClientRetry(self, for: request, withError: error) else { throw error } - return try await actuallySend(request) + guard try await self.delegate.shouldClientRetry(self, for: request, withError: error) else { throw error } + return try await actuallySend(request, delegate: delegate) } } - private func actuallySend(_ request: URLRequest) async throws -> Response { + private func actuallySend(_ request: URLRequest, delegate: URLSessionDataDelegate?) async throws -> Response { var request = request - try await delegate.client(self, willSendRequest: &request) - let (data, response, metrics) = try await loader.data(for: request, session: session) + try await self.delegate.client(self, willSendRequest: &request) + let (data, response, metrics) = try await loader.data(for: request, session: session, delegate: delegate) try validate(response: response, data: data) return Response(value: data, data: data, request: request, response: response, metrics: metrics) } diff --git a/Sources/Get/DataLoader.swift b/Sources/Get/DataLoader.swift new file mode 100644 index 0000000..fd9754f --- /dev/null +++ b/Sources/Get/DataLoader.swift @@ -0,0 +1,188 @@ +// The MIT License (MIT) +// +// Copyright (c) 2021-2022 Alexander Grebenyuk (github.com/kean). + +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +// A simple URLSession wrapper adding async/await APIs compatible with older platforms. +final class DataLoader: NSObject, URLSessionDataDelegate { + private var handlers = [URLSessionTask: TaskHandler]() + private typealias Completion = (Result<(Data, URLResponse, URLSessionTaskMetrics?), Error>) -> Void + + /// Loads data with the given request. + func data(for request: URLRequest, session: URLSession, delegate: URLSessionDataDelegate?) async throws -> (Data, URLResponse, URLSessionTaskMetrics?) { + final class Box { var task: URLSessionTask? } + let box = Box() + return try await withTaskCancellationHandler(handler: { + box.task?.cancel() + }, operation: { + try await withUnsafeThrowingContinuation { continuation in + box.task = self.loadData(with: request, session: session, delegate: delegate) { result in + continuation.resume(with: result) + } + } + }) + } + + private func loadData(with request: URLRequest, session: URLSession, delegate: URLSessionDataDelegate?, completion: @escaping Completion) -> URLSessionTask { + let task = session.dataTask(with: request) + session.delegateQueue.addOperation { + self.handlers[task] = TaskHandler(delegate: delegate, completion: completion) + } + task.resume() + return task + } + + private final class TaskHandler { + let delegate: URLSessionDataDelegate? + let completion: Completion + var data: Data? + var metrics: URLSessionTaskMetrics? + + init(delegate: URLSessionDataDelegate?, completion: @escaping Completion) { + self.delegate = delegate + self.completion = completion + } + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + guard let handler = handlers[dataTask] else { return } +#if os(Linux) + handler.delegate?.urlSession(session, dataTask: dataTask, didReceive: data) +#else + handler.delegate?.urlSession?(session, dataTask: dataTask, didReceive: data) +#endif + if handler.data == nil { + handler.data = Data() + } + handler.data!.append(data) + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + guard let handler = handlers[task] else { return } + handlers[task] = nil +#if os(Linux) + handler.delegate?.urlSession(session, task: task, didCompleteWithError: error) +#else + handler.delegate?.urlSession?(session, task: task, didCompleteWithError: error) +#endif + if let response = task.response, error == nil { + handler.completion(.success((handler.data ?? Data(), response, handler.metrics))) + } else { + handler.completion(.failure(error ?? URLError(.unknown))) + } + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) { + handlers[task]?.metrics = metrics + } +} + +#if !os(Linux) +extension DataLoader { + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { + if handlers[dataTask]?.delegate?.urlSession?(session, dataTask: dataTask, didReceive: response, completionHandler: completionHandler) != nil { + return + } + completionHandler(.allow) + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didBecome downloadTask: URLSessionDownloadTask) { + handlers[dataTask]?.delegate?.urlSession?(session, dataTask: dataTask, didBecome: downloadTask) + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didBecome streamTask: URLSessionStreamTask) { + handlers[dataTask]?.delegate?.urlSession?(session, dataTask: dataTask, didBecome: streamTask) + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, willCacheResponse proposedResponse: CachedURLResponse, completionHandler: @escaping (CachedURLResponse?) -> Void) { + if handlers[dataTask]?.delegate?.urlSession?(session, dataTask: dataTask, willCacheResponse: proposedResponse, completionHandler: completionHandler) != nil { + // Do nothing, delegate called + } else { + completionHandler(proposedResponse) + } + } + + func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) { + if handlers[task]?.delegate?.urlSession?(session, task: task, willPerformHTTPRedirection: response, newRequest: request, completionHandler: completionHandler) != nil { + // Do nothing, delegate called + } else { + completionHandler(request) + } + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { + if handlers[task]?.delegate?.urlSession?(session, task: task, didReceive: challenge, completionHandler: completionHandler) != nil { + // Do nothing, delegate called + } else { + completionHandler(.performDefaultHandling, nil) + } + } + + func urlSession(_ session: URLSession, task: URLSessionTask, willBeginDelayedRequest request: URLRequest, completionHandler: @escaping (URLSession.DelayedRequestDisposition, URLRequest?) -> Void) { + if handlers[task]?.delegate?.urlSession?(session, task: task, willBeginDelayedRequest: request, completionHandler: completionHandler) != nil { + // Do nothing, delegate called + } else { + completionHandler(.continueLoading, nil) + } + } + + func urlSession(_ session: URLSession, taskIsWaitingForConnectivity task: URLSessionTask) { + handlers[task]?.delegate?.urlSession?(session, taskIsWaitingForConnectivity: task) + } + +#if swift(>=5.7) + func urlSession(_ session: URLSession, didCreateTask task: URLSessionTask) { + if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { + handlers[task]?.delegate?.urlSession?(session, didCreateTask: task) + } else { + // Doesn't exist on earlier versions + } + } +#endif +} +#else +extension DataLoader { + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { + if handlers[dataTask]?.delegate?.urlSession(session, dataTask: dataTask, didReceive: response, completionHandler: completionHandler) != nil { + return + } + completionHandler(.allow) + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, willCacheResponse proposedResponse: CachedURLResponse, completionHandler: @escaping (CachedURLResponse?) -> Void) { + if handlers[dataTask]?.delegate?.urlSession(session, dataTask: dataTask, willCacheResponse: proposedResponse, completionHandler: completionHandler) != nil { + // Do nothing, delegate called + } else { + completionHandler(proposedResponse) + } + } + + func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) { + if handlers[task]?.delegate?.urlSession(session, task: task, willPerformHTTPRedirection: response, newRequest: request, completionHandler: completionHandler) != nil { + // Do nothing, delegate called + } else { + completionHandler(request) + } + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { + if handlers[task]?.delegate?.urlSession(session, task: task, didReceive: challenge, completionHandler: completionHandler) != nil { + // Do nothing, delegate called + } else { + completionHandler(.performDefaultHandling, nil) + } + } + + func urlSession(_ session: URLSession, task: URLSessionTask, willBeginDelayedRequest request: URLRequest, completionHandler: @escaping (URLSession.DelayedRequestDisposition, URLRequest?) -> Void) { + if handlers[task]?.delegate?.urlSession(session, task: task, willBeginDelayedRequest: request, completionHandler: completionHandler) != nil { + // Do nothing, delegate called + } else { + completionHandler(.continueLoading, nil) + } + } +} +#endif diff --git a/Sources/Get/Helpers.swift b/Sources/Get/Helpers.swift index 9d1e28f..2ef4474 100644 --- a/Sources/Get/Helpers.swift +++ b/Sources/Get/Helpers.swift @@ -47,70 +47,6 @@ actor Serializer { } } -// A simple URLSession wrapper adding async/await APIs compatible with older platforms. -final class DataLoader: NSObject, URLSessionDataDelegate { - private var handlers = [URLSessionTask: TaskHandler]() - private typealias Completion = (Result<(Data, URLResponse, URLSessionTaskMetrics?), Error>) -> Void - - /// Loads data with the given request. - func data(for request: URLRequest, session: URLSession) async throws -> (Data, URLResponse, URLSessionTaskMetrics?) { - final class Box { var task: URLSessionTask? } - let box = Box() - return try await withTaskCancellationHandler(handler: { - box.task?.cancel() - }, operation: { - try await withUnsafeThrowingContinuation { continuation in - box.task = self.loadData(with: request, session: session) { result in - continuation.resume(with: result) - } - } - }) - } - - private func loadData(with request: URLRequest, session: URLSession, completion: @escaping Completion) -> URLSessionTask { - let task = session.dataTask(with: request) - session.delegateQueue.addOperation { - self.handlers[task] = TaskHandler(completion: completion) - } - task.resume() - return task - } - - func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { - guard let handler = handlers[task] else { return } - handlers[task] = nil - if let response = task.response, error == nil { - handler.completion(.success((handler.data ?? Data(), response, handler.metrics))) - } else { - handler.completion(.failure(error ?? URLError(.unknown))) - } - } - - func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) { - handlers[task]?.metrics = metrics - } - - func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { - guard let handler = handlers[dataTask] else { - return - } - if handler.data == nil { - handler.data = Data() - } - handler.data!.append(data) - } - - private final class TaskHandler { - var data: Data? - var metrics: URLSessionTaskMetrics? - let completion: Completion - - init(completion: @escaping Completion) { - self.completion = completion - } - } -} - #if !os(Linux) /// Allows users to monitor URLSession. @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) diff --git a/Tests/GetTests/ClientAuthorizationTests.swift b/Tests/GetTests/ClientAuthorizationTests.swift index bd4c181..4172410 100644 --- a/Tests/GetTests/ClientAuthorizationTests.swift +++ b/Tests/GetTests/ClientAuthorizationTests.swift @@ -9,7 +9,7 @@ import FoundationNetworking @testable import Get final class APIClientAuthorizationTests: XCTestCase { - + func testAuthorizationHeaderWidhValidToken() async throws { // GIVEN let (client, delegate) = makeSUT() @@ -17,7 +17,7 @@ final class APIClientAuthorizationTests: XCTestCase { delegate.token = Token(value: "valid-token", expiresDate: Date(timeIntervalSinceNow: 1000)) let url = URL(string: "https://api.github.com/user")! var mock = Mock.get(url: url, json: "user") - mock.onRequest = { request, arguments in + mock.onRequest = { request, _ in XCTAssertEqual(request.allHTTPHeaderFields?["Authorization"], "token valid-token") } mock.register() @@ -25,7 +25,7 @@ final class APIClientAuthorizationTests: XCTestCase { // WHEN try await client.send(.get("/user")) } - + func testAuthorizationHeaderWithExpiredToken() async throws { // GIVEN let (client, delegate) = makeSUT() @@ -33,11 +33,11 @@ final class APIClientAuthorizationTests: XCTestCase { delegate.token = Token(value: "expired-token", expiresDate: Date(timeIntervalSinceNow: -1000)) let url = URL(string: "https://api.github.com/user")! var mock = Mock.get(url: url, json: "user") - mock.onRequest = { request, arguments in + mock.onRequest = { request, _ in XCTAssertEqual(request.allHTTPHeaderFields?["Authorization"], "token valid-token") } mock.register() - + // WHEN try await client.send(.get("/user")) } @@ -51,11 +51,11 @@ final class APIClientAuthorizationTests: XCTestCase { var mock = Mock(url: url, dataType: .json, statusCode: 401, data: [ .get: "Unauthorized".data(using: .utf8)! ]) - mock.onRequest = { request, arguments in + mock.onRequest = { request, _ in XCTAssertEqual(request.allHTTPHeaderFields?["Authorization"], "token invalid-token") var mock = Mock.get(url: url, json: "user") - mock.onRequest = { request, arguments in + mock.onRequest = { request, _ in XCTAssertEqual(request.allHTTPHeaderFields?["Authorization"], "token valid-token") } mock.register() @@ -78,7 +78,7 @@ final class APIClientAuthorizationTests: XCTestCase { } trackForMemoryLeak(client, file: file, line: line) - + return (client, delegate) } } @@ -97,7 +97,7 @@ private final class MockAuthorizingDelegate: APIClientDelegate { request.addValue("token \(token.value)", forHTTPHeaderField: "Authorization") } - + func shouldClientRetry(_ client: APIClient, for request: URLRequest, withError error: Error) async throws -> Bool { if case .unacceptableStatusCode(let statusCode) = (error as? APIError), statusCode == 401 { token = try await tokenRefresher.refreshToken() diff --git a/Tests/GetTests/ClientIIntegrationTests.swift b/Tests/GetTests/ClientIIntegrationTests.swift index 3d2fae3..bf420df 100644 --- a/Tests/GetTests/ClientIIntegrationTests.swift +++ b/Tests/GetTests/ClientIIntegrationTests.swift @@ -10,7 +10,7 @@ final class APIClientIntegrationTests: XCTestCase { func _testGitHubUsersApi() async throws { let sut = makeSUT() let user = try await sut.send(Paths.users("kean").get).value - + XCTAssertEqual(user.login, "kean") } @@ -25,5 +25,5 @@ final class APIClientIntegrationTests: XCTestCase { return client } - + } diff --git a/Tests/GetTests/ClientSessionDelegateTests.swift b/Tests/GetTests/ClientSessionDelegateTests.swift index b267ccd..3bd760c 100644 --- a/Tests/GetTests/ClientSessionDelegateTests.swift +++ b/Tests/GetTests/ClientSessionDelegateTests.swift @@ -12,16 +12,16 @@ final class APIClientSessionDelegateTests: XCTestCase { #if os(watchOS) throw XCTSkip("Mocker URLProtocol isn't being called for requests on watchOS") #endif - + // GIVEN let (client, delegate) = makeSUT() let url = URL(string: "https://api.github.com/user")! Mock.get(url: url, json: "user").register() - + // WHEN try await client.send(.get("/user")) - + // THEN XCTAssertEqual(delegate.metrics.count, 1) let metrics = try XCTUnwrap(delegate.metrics.first?.value) @@ -49,7 +49,7 @@ final class APIClientSessionDelegateTests: XCTestCase { private final class SessionDelegate: NSObject, URLSessionTaskDelegate { var metrics: [URLSessionTask: URLSessionTaskMetrics] = [:] - + func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) { self.metrics[task] = metrics } diff --git a/Tests/GetTests/ClientTests.swift b/Tests/GetTests/ClientTests.swift index ddba938..40cf4f6 100644 --- a/Tests/GetTests/ClientTests.swift +++ b/Tests/GetTests/ClientTests.swift @@ -8,7 +8,7 @@ import XCTest final class APIClientTests: XCTestCase { // MARK: Basic Requests - + // You don't need to provide a predefined list of resources in your app. // You can define the requests inline instead. func testDefiningRequestInline() async throws { @@ -17,24 +17,24 @@ final class APIClientTests: XCTestCase { let url = URL(string: "https://api.github.com/user")! Mock.get(url: url, json: "user").register() - + // WHEN let user: User = try await client.send(.get("/user")).value - + // THEN XCTAssertEqual(user.login, "kean") } - + func testResponseMetadata() async throws { // GIVEN let client = makeSUT() let url = URL(string: "https://api.github.com/user")! Mock.get(url: url, json: "user").register() - + // WHEN let response = try await client.send(Paths.user.get) - + // THEN the client returns not just the value, but data, original // request, and more XCTAssertEqual(response.value.login, "kean") @@ -47,7 +47,7 @@ final class APIClientTests: XCTestCase { XCTAssertEqual(transaction.request.url, URL(string: "https://api.github.com/user")!) #endif } - + func testCancellingRequests() async throws { // Given let client = makeSUT() @@ -56,27 +56,27 @@ final class APIClientTests: XCTestCase { var mock = Mock.get(url: url, json: "user") mock.delay = DispatchTimeInterval.seconds(60) mock.register() - + // When let task = Task { try await client.send(.get("/users/kean")) } - + DispatchQueue.global().asyncAfter(deadline: .now() + .milliseconds(100)) { task.cancel() } - + // Then do { - let _ = try await task.value + _ = try await task.value } catch { XCTAssertTrue(error is URLError) XCTAssertEqual((error as? URLError)?.code, .cancelled) } } - + // MARK: Response Types - + // func value(for:) -> Decodable func testResponseDecodable() async throws { // GIVEN @@ -84,14 +84,14 @@ final class APIClientTests: XCTestCase { let url = URL(string: "https://api.github.com/user")! Mock.get(url: url, json: "user").register() - + // WHEN let user: User = try await client.send(.get("/user")).value - + // THEN returns decoded JSON XCTAssertEqual(user.login, "kean") } - + // func value(for:) -> Decodable func testResponseDecodableOptional() async throws { // GIVEN @@ -101,14 +101,14 @@ final class APIClientTests: XCTestCase { Mock(url: url, dataType: .html, statusCode: 200, data: [ .get: Data() ]).register() - + // WHEN let user: User? = try await client.send(.get("/user")).value - + // THEN returns decoded JSON XCTAssertNil(user) } - + // func value(for:) -> Decodable func testResponseEmpty() async throws { // GIVEN @@ -118,11 +118,11 @@ final class APIClientTests: XCTestCase { Mock(url: url, dataType: .html, statusCode: 200, data: [ .get: Data() ]).register() - + // WHEN try await client.send(.get("/user")).value } - + // func value(for:) -> Data func testResponseData() async throws { // GIVEN @@ -132,14 +132,14 @@ final class APIClientTests: XCTestCase { Mock(url: url, dataType: .html, statusCode: 200, data: [ .get: "Hello".data(using: .utf8)! ]).register() - + // WHEN let data: Data = try await client.send(.get("/user")).value - + // THEN return unprocessed data (NOT what Data: Decodable does by default) XCTAssertEqual(String(data: data, encoding: .utf8), "Hello") } - + // func value(for:) -> String func testResponeString() async throws { // GIVEN @@ -152,16 +152,16 @@ final class APIClientTests: XCTestCase { // WHEN let text: String = try await client.send(.get("/user")).value - + // THEN XCTAssertEqual(text, "hello") } - + func testDecodingWithVoidResponse() async throws { #if os(watchOS) throw XCTSkip("Mocker URLProtocol isn't being called for POST requests on watchOS") #endif - + // GIVEN let client = makeSUT() @@ -169,19 +169,19 @@ final class APIClientTests: XCTestCase { Mock(url: url, dataType: .json, statusCode: 200, data: [ .post: json(named: "user") ]).register() - + // WHEN let request = Request.post("/user", body: ["login": "kean"]) try await client.send(request) } - + // MARK: - Request Body - + func testPassEncodableRequestBody() async throws { #if os(watchOS) throw XCTSkip("Mocker URLProtocol isn't being called for POST requests on watchOS") #endif - + // GIVEN let client = makeSUT() @@ -189,7 +189,7 @@ final class APIClientTests: XCTestCase { var mock = Mock(url: url, dataType: .json, statusCode: 200, data: [ .post: json(named: "user") ]) - mock.onRequest = { request, arguments in + mock.onRequest = { request, _ in guard let body = request.httpBody ?? request.httpBodyStream?.data, let json = try? JSONSerialization.jsonObject(with: body, options: []), let user = json as? [String: Any] else { @@ -199,18 +199,18 @@ final class APIClientTests: XCTestCase { XCTAssertEqual(user["login"] as? String, "kean") } mock.register() - + // WHEN let body = User(id: 1, login: "kean") let request = Request.post("/user", body: body) try await client.send(request) } - + func testPassingNilBody() async throws { #if os(watchOS) throw XCTSkip("Mocker URLProtocol isn't being called for POST requests on watchOS") #endif - + // GIVEN let client = makeSUT() @@ -218,18 +218,102 @@ final class APIClientTests: XCTestCase { var mock = Mock(url: url, dataType: .json, statusCode: 200, data: [ .post: json(named: "user") ]) - mock.onRequest = { request, arguments in + mock.onRequest = { request, _ in XCTAssertNil(request.httpBody) XCTAssertNil(request.httpBodyStream) } mock.register() - + // WHEN let body: User? = nil let request = Request.post("/user", body: body) try await client.send(request) } + // MARK: - URLSessionDataDelegate (Per Request) + +#if !os(Linux) + func testSettingDelegate() async throws { +#if os(watchOS) + throw XCTSkip("Mocker URLProtocol isn't being called for POST requests on watchOS") +#endif + + // GIVEN + final class MockDelegate: NSObject, URLSessionDataDelegate { + var response: URLResponse? + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse) async -> URLSession.ResponseDisposition { + self.response = response + return .cancel + } + } + + let client = makeSUT() + let delegate = MockDelegate() + + let url = URL(string: "https://api.github.com/user")! + var mock = Mock(url: url, dataType: .json, statusCode: 200, data: [ + .post: json(named: "user") + ]) + mock.onRequest = { request, _ in + XCTAssertNil(request.httpBody) + XCTAssertNil(request.httpBodyStream) + } + mock.register() + + // WHEN + let body: User? = nil + let request = Request.post("/user", body: body) + do { + try await client.send(request, delegate: delegate) + XCTFail("Request was supposed to be cancelled") + } catch { + // Do nothing + } + + // THEN + XCTAssertEqual(delegate.response?.url, url) + } + + func testSettingDelegateCallbackBased() async throws { + // GIVEN + final class MockDelegate: NSObject, URLSessionDataDelegate { + var response: URLResponse? + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { + self.response = response + completionHandler(.cancel) + } + } + + let client = makeSUT() + let delegate = MockDelegate() + + let url = URL(string: "https://api.github.com/user")! + var mock = Mock(url: url, dataType: .json, statusCode: 200, data: [ + .post: json(named: "user") + ]) + mock.onRequest = { request, _ in + XCTAssertNil(request.httpBody) + XCTAssertNil(request.httpBodyStream) + } + mock.register() + + // WHEN + let body: User? = nil + let request = Request.post("/user", body: body) + do { + try await client.send(request, delegate: delegate) + XCTFail("Request was supposed to be cancelled") + } catch { + // Do nothing + } + + // THEN + XCTAssertEqual(delegate.response?.url, url) + } +#endif + // MARK: - Helpers private func makeSUT(using baseURL: URL? = URL(string: "https://api.github.com"), diff --git a/Tests/GetTests/GitHubAPI.swift b/Tests/GetTests/GitHubAPI.swift index c59ffe8..dd3ec05 100644 --- a/Tests/GetTests/GitHubAPI.swift +++ b/Tests/GetTests/GitHubAPI.swift @@ -16,10 +16,10 @@ public enum Paths {} extension Paths { public static var user: UserResource { UserResource() } - + public struct UserResource { public let path: String = "/user" - + public var get: Request { .get(path) } } } @@ -28,16 +28,16 @@ extension Paths { extension Paths.UserResource { public var emails: EmailsResource { EmailsResource() } - + public struct EmailsResource { public let path: String = "/user/emails" - + public var get: Request<[UserEmail]> { .get(path) } - + public func post(_ emails: [String]) -> Request { .post(path, body: emails) } - + public func delete() -> Request { .delete(path) } @@ -50,10 +50,10 @@ extension Paths { public static func users(_ name: String) -> UsersResource { UsersResource(path: "/users/\(name)") } - + public struct UsersResource { public let path: String - + public var get: Request { .get(path) } } } @@ -62,10 +62,10 @@ extension Paths { extension Paths.UsersResource { public var followers: FollowersResource { FollowersResource(path: path + "/followers") } - + public struct FollowersResource { public let path: String - + public var get: Request<[User]> { .get(path) } } } @@ -98,38 +98,37 @@ private final class GitHubAPIClientDelegate: APIClientDelegate { func client(_ client: APIClient, willSendRequest request: inout URLRequest) async throws { request.setValue("Bearer \("your-access-token")", forHTTPHeaderField: "Authorization") } - + func shouldClientRetry(_ client: APIClient, for request: URLRequest, withError error: Error) async throws -> Bool { if case .unacceptableStatusCode(let status) = (error as? GitHubError), status == 401 { return await refreshAccessToken() } return false } - + private func refreshAccessToken() async -> Bool { // TODO: Refresh (make sure you only refresh once if multiple requests fail) return false } - + func client(_ client: APIClient, didReceiveInvalidResponse response: HTTPURLResponse, data: Data) -> Error { GitHubError.unacceptableStatusCode(response.statusCode) } } - // MARK: - Usage func usage() async throws { let client = APIClient(baseURL: URL(string: "https://api.github.com")) { $0.delegate = GitHubAPIClientDelegate() } - - let _ = try await client.send(Paths.user.get) - let _ = try await client.send(Paths.user.emails.get) - + + _ = try await client.send(Paths.user.get) + _ = try await client.send(Paths.user.emails.get) + // try await client.send(Resources.user.emails.delete(["octocat@gmail.com"])) - - let _ = try await client.send(Paths.users("kean").followers.get) - + + _ = try await client.send(Paths.users("kean").followers.get) + let _: User = try await client.send(.get("/user")).value }