diff --git a/Tests/ApolloInternalTestHelpers/MockHTTPResponse.swift b/Tests/ApolloInternalTestHelpers/MockHTTPResponse.swift index 9be77c244..f9e5a67cf 100644 --- a/Tests/ApolloInternalTestHelpers/MockHTTPResponse.swift +++ b/Tests/ApolloInternalTestHelpers/MockHTTPResponse.swift @@ -2,23 +2,6 @@ import Apollo import ApolloAPI import Foundation -extension HTTPResponse { - public static func mock( - statusCode: Int = 200, - headerFields: [String : String] = [:], - data: Data = Data() - ) -> HTTPResponse { - return HTTPResponse( - response: .mock( - statusCode: statusCode, - headerFields: headerFields - ), - rawData: data, - parsedResponse: nil - ) - } -} - extension HTTPURLResponse { public static func mock( url: URL = TestURL.mockServer.url, diff --git a/Tests/ApolloInternalTestHelpers/MockResponseProvider.swift b/Tests/ApolloInternalTestHelpers/MockResponseProvider.swift new file mode 100644 index 000000000..a24448ed4 --- /dev/null +++ b/Tests/ApolloInternalTestHelpers/MockResponseProvider.swift @@ -0,0 +1,119 @@ +import Foundation + +/// A protocol that a type can conform to to provide mock responses to a ``MockURLSession``. +/// +/// Typically an `XCTestCase` will conform to this protocol directly. +/// +/// - Important: To ensure test isolation, requests handlers must be cleaned up after each +/// individual test by calling `Self.cleanUpRequestHandlers()`. +/// +/// This is an example of how an `XCTestCase` should use this protocol. +/// +/// ``` +/// class MyTests: XCTestCase, MockResponseProvider { +/// +/// var session: MockURLSession! +/// +/// override func setUp() async throws { +/// try await super.setUp() +/// +/// session = MockURLSession(requestProvider: Self.self) +/// } +/// +/// override func tearDown() async throws { +/// await Self.cleanUpRequestHandlers() +/// session = nil +/// sessionConfiguration = nil +/// +/// try await super.tearDown() +/// } +/// +/// func testThatMocksAResponse() async throws { +/// let url = URL(string: "www.test.com")! +/// let responseStrings: [String] = [ +/// ... // An array of strings for each multipart response chunk in your mocked response. +/// ] +/// await Self.registerRequestHandler(for: url) { request in +/// let response = HTTPURLResponse( +/// url: url, +/// statusCode: 200, +/// httpVersion: nil, +/// headerFields: nil +/// ) +/// +/// let stream = AsyncThrowingStream { continuation in +/// for string in responseStrings { +/// continuation.yield(string.data(using: .utf8)!) +/// } +/// continuation.finish() +/// } +/// +/// return (response. stream) +/// } +/// +/// // Send mockRequest +/// let (dataStream, response) = try await self.session.bytes(for: request, delegate: nil) +/// +/// for try await chunk in dataStream.chunks { +/// let chunkString = String(data: chunk, encoding: .utf8) +/// +/// ... // test assertions on response +/// } +/// } +/// ``` +public protocol MockResponseProvider { + typealias MultiResponseHandler = @Sendable (URLRequest) async throws -> (HTTPURLResponse, AsyncThrowingStream?) + typealias SingleResponseHandler = @Sendable (URLRequest) async throws -> (HTTPURLResponse, Data?) +} + +extension MockResponseProvider { + + public static func registerRequestHandler(for url: URL, handler: @escaping MultiResponseHandler) async { + await requestStorage.registerRequestHandler(for: Self.self, url: url, handler: handler) + } + + public static func registerRequestHandler(for url: URL, handler: @escaping SingleResponseHandler) async { + await requestStorage.registerRequestHandler(for: Self.self, url: url, handler: { request in + let (response, data) = try await handler(request) + let stream = AsyncThrowingStream { data } + + return (response, stream) + }) + } + + public static func requestHandler(for url: URL) async -> MultiResponseHandler? { + await requestStorage.requestHandler(for: Self.self, url: url) + } + + /// Removes all request handlers for the provider type. + /// + /// - Important: To ensure test isolation, requests handlers must be cleaned up after each + /// individual test. + public static func cleanUpRequestHandlers() async { + await requestStorage.removeRequestHandlers(for: Self.self) + } +} + +fileprivate let requestStorage = RequestStorage() +fileprivate actor RequestStorage { + private var providers: [ObjectIdentifier: [URL: MockResponseProvider.MultiResponseHandler]] = [:] + + func registerRequestHandler( + for type: Any.Type, + url: URL, + handler: @escaping MockResponseProvider.MultiResponseHandler + ) { + let typeId = ObjectIdentifier(type) + var handlers = providers[typeId, default: [:]] + handlers[url] = handler + providers[typeId] = handlers + } + + func requestHandler(for type: Any.Type, url: URL) -> MockResponseProvider.MultiResponseHandler? { + providers[ObjectIdentifier(type)]?[url] + } + + func removeRequestHandlers(for type: Any.Type) { + providers[ObjectIdentifier(type)] = nil + } +} diff --git a/Tests/ApolloInternalTestHelpers/MockURLProtocol.swift b/Tests/ApolloInternalTestHelpers/MockURLProtocol.swift index dcf4ae049..683346a34 100644 --- a/Tests/ApolloInternalTestHelpers/MockURLProtocol.swift +++ b/Tests/ApolloInternalTestHelpers/MockURLProtocol.swift @@ -1,7 +1,7 @@ import Foundation -public class MockURLProtocol: URLProtocol { - +public final class MockURLProtocol: URLProtocol { + override class public func canInit(with request: URLRequest) -> Bool { return true } @@ -9,34 +9,37 @@ public class MockURLProtocol: URLProtocol override class public func canonicalRequest(for request: URLRequest) -> URLRequest { return request } - + + private var asyncTask: Task? + override public func startLoading() { - guard - let url = self.request.url, - let handler = RequestProvider.requestHandlers[url] - else { return } - - DispatchQueue.main.asyncAfter(deadline: .now() + Double.random(in: 0.0...0.25)) { - defer { - RequestProvider.requestHandlers.removeValue(forKey: url) - } - + self.asyncTask = Task { + guard + let url = self.request.url, + let handler = await RequestProvider.requestHandler(for: url) + else { return } + do { - let result = try handler(self.request) - - switch result { - case let .success((response, data)): - self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - - if let data = data { - self.client?.urlProtocol(self, didLoad: data) - } - + defer { self.client?.urlProtocolDidFinishLoading(self) - case let .failure(error): - self.client?.urlProtocol(self, didFailWithError: error) } - + + let (response, dataStream) = try await handler(self.request) + + try Task.checkCancellation() + + self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + + guard let dataStream else { + return + } + + for try await data in dataStream { + try Task.checkCancellation() + + self.client?.urlProtocol(self, didLoad: data) + } + } catch { self.client?.urlProtocol(self, didFailWithError: error) } @@ -44,13 +47,7 @@ public class MockURLProtocol: URLProtocol } override public func stopLoading() { + self.asyncTask?.cancel() } } - -public protocol MockRequestProvider { - typealias MockRequestHandler = ((URLRequest) throws -> Result<(HTTPURLResponse, Data?), any Error>) - - // Dictionary of mock request handlers where the `key` is the URL of the request. - static var requestHandlers: [URL: MockRequestHandler] { get set } -} diff --git a/Tests/ApolloInternalTestHelpers/MockURLSession.swift b/Tests/ApolloInternalTestHelpers/MockURLSession.swift index d1b9d44bf..c2cef6916 100644 --- a/Tests/ApolloInternalTestHelpers/MockURLSession.swift +++ b/Tests/ApolloInternalTestHelpers/MockURLSession.swift @@ -2,75 +2,98 @@ import Foundation import Apollo import ApolloAPI -public final class MockURLSessionClient: URLSessionClient { +public struct MockURLSession: ApolloURLSession { - @Atomic public var lastRequest: URLRequest? - @Atomic public var requestCount = 0 + public let session: URLSession - public var jsonData: JSONObject? - public var data: Data? - var responseData: Data? { - if let data = data { return data } - if let jsonData = jsonData { - return try! JSONSerializationFormat.serialize(value: jsonData) - } - return nil + public init(responseProvider: T.Type) { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [MockURLProtocol.self] + session = URLSession(configuration: configuration) } - public var response: HTTPURLResponse? - public var error: (any Error)? - - private let callbackQueue: DispatchQueue - - public init(callbackQueue: DispatchQueue? = nil, response: HTTPURLResponse? = nil, data: Data? = nil) { - self.callbackQueue = callbackQueue ?? .main - self.response = response - self.data = data - } - - public override func sendRequest(_ request: URLRequest, - taskDescription: String? = nil, - rawTaskCompletionHandler: URLSessionClient.RawCompletion? = nil, - completion: @escaping URLSessionClient.Completion) -> URLSessionTask { - self.$lastRequest.mutate { $0 = request } - self.$requestCount.increment() - - // Capture data, response, and error instead of self to ensure we complete with the current state - // even if it is changed before the block runs. - callbackQueue.async { [responseData, response, error] in - rawTaskCompletionHandler?(responseData, response, error) - - if let error = error { - completion(.failure(error)) - } else { - guard let data = responseData else { - completion(.failure(URLSessionClientError.dataForRequestNotFound(request: request))) - return - } - - guard let response = response else { - completion(.failure(URLSessionClientError.noHTTPResponse(request: request))) - return - } - - completion(.success((data, response))) - } - } - let mockTaskType: any URLSessionDataTaskMockProtocol.Type = URLSessionDataTaskMock.self - let mockTask = mockTaskType.init() as! URLSessionDataTaskMock - return mockTask + public func bytes( + for request: URLRequest, + delegate: (any URLSessionTaskDelegate)? + ) async throws -> (URLSession.AsyncBytes, URLResponse) { + return try await session.bytes(for: request, delegate: delegate) } -} -protocol URLSessionDataTaskMockProtocol { - init() -} - -private final class URLSessionDataTaskMock: URLSessionDataTask, URLSessionDataTaskMockProtocol { - - override func resume() { - // No-op + public func invalidateAndCancel() { + session.invalidateAndCancel() } - - override func cancel() {} } + +#warning("TODO: Delete this") +//public final class MockURLSessionClient: URLSessionClient { +// +// @Atomic public var lastRequest: URLRequest? +// @Atomic public var requestCount = 0 +// +// public var jsonData: JSONObject? +// public var data: Data? +// var responseData: Data? { +// if let data = data { return data } +// if let jsonData = jsonData { +// return try! JSONSerializationFormat.serialize(value: jsonData) +// } +// return nil +// } +// public var response: HTTPURLResponse? +// public var error: (any Error)? +// +// private let callbackQueue: DispatchQueue +// +// public init(callbackQueue: DispatchQueue? = nil, response: HTTPURLResponse? = nil, data: Data? = nil) { +// self.callbackQueue = callbackQueue ?? .main +// self.response = response +// self.data = data +// } +// +// public override func sendRequest(_ request: URLRequest, +// taskDescription: String? = nil, +// rawTaskCompletionHandler: URLSessionClient.RawCompletion? = nil, +// completion: @escaping URLSessionClient.Completion) -> URLSessionTask { +// self.$lastRequest.mutate { $0 = request } +// self.$requestCount.increment() +// +// // Capture data, response, and error instead of self to ensure we complete with the current state +// // even if it is changed before the block runs. +// callbackQueue.async { [responseData, response, error] in +// rawTaskCompletionHandler?(responseData, response, error) +// +// if let error = error { +// completion(.failure(error)) +// } else { +// guard let data = responseData else { +// completion(.failure(URLSessionClientError.dataForRequestNotFound(request: request))) +// return +// } +// +// guard let response = response else { +// completion(.failure(URLSessionClientError.noHTTPResponse(request: request))) +// return +// } +// +// completion(.success((data, response))) +// } +// } +// +// let mockTaskType: any URLSessionDataTaskMockProtocol.Type = URLSessionDataTaskMock.self +// let mockTask = mockTaskType.init() as! URLSessionDataTaskMock +// return mockTask +// } +//} +// +//protocol URLSessionDataTaskMockProtocol { +// init() +//} +// +//private final class URLSessionDataTaskMock: URLSessionDataTask, URLSessionDataTaskMockProtocol { +// +// override func resume() { +// // No-op +// } +// +// override func cancel() {} +//} diff --git a/Tests/ApolloTests/BlindRetryingTestInterceptor.swift b/Tests/ApolloTests/BlindRetryingTestInterceptor.swift index c509c6eed..f2b3662b4 100644 --- a/Tests/ApolloTests/BlindRetryingTestInterceptor.swift +++ b/Tests/ApolloTests/BlindRetryingTestInterceptor.swift @@ -3,20 +3,18 @@ import Apollo import ApolloAPI // An interceptor which blindly retries every time it receives a request. -class BlindRetryingTestInterceptor: ApolloInterceptor { +class BlindRetryingTestInterceptor: ApolloInterceptor, @unchecked Sendable { var hitCount = 0 private(set) var hasBeenCancelled = false public var id: String = UUID().uuidString - func interceptAsync( - chain: any RequestChain, + func intercept( request: HTTPRequest, - response: HTTPResponse?, - completion: @escaping (Result, any Error>) -> Void) { + next: NextInterceptorFunction + ) async throws -> InterceptorResultStream { self.hitCount += 1 - chain.retry(request: request, - completion: completion) + throw RequestChainRetry() } // Purposely not adhering to `Cancellable` here to make sure non `Cancellable` interceptors don't have this called. diff --git a/Tests/ApolloTests/Interceptors/MaxRetryInterceptorTests.swift b/Tests/ApolloTests/Interceptors/MaxRetryInterceptorTests.swift index c21568baf..7f8a86bfc 100644 --- a/Tests/ApolloTests/Interceptors/MaxRetryInterceptorTests.swift +++ b/Tests/ApolloTests/Interceptors/MaxRetryInterceptorTests.swift @@ -1,103 +1,96 @@ import XCTest -import Apollo +import Nimble +@testable import Apollo import ApolloAPI import ApolloInternalTestHelpers class MaxRetryInterceptorTests: XCTestCase { - - func testMaxRetryInterceptorErrorsAfterMaximumRetries() { - class TestProvider: InterceptorProvider { - let testInterceptor = BlindRetryingTestInterceptor() - let retryCount = 15 - - func interceptors( - for operation: Operation - ) -> [any ApolloInterceptor] { - [ - MaxRetryInterceptor(maxRetriesAllowed: self.retryCount), - self.testInterceptor - ] - } + + override func tearDown() async throws { + await TestProvider.cleanUpRequestHandlers() + + try await super.tearDown() + } + + class TestProvider: InterceptorProvider, MockResponseProvider { + let testInterceptor: any ApolloInterceptor + let retryCount: Int + + init(testInterceptor: any ApolloInterceptor, retryCount: Int) { + self.testInterceptor = testInterceptor + self.retryCount = retryCount } - let testProvider = TestProvider() + func interceptors( + for operation: Operation + ) -> [any ApolloInterceptor] { + [ + MaxRetryInterceptor(maxRetriesAllowed: self.retryCount), + self.testInterceptor + ] + } + + func urlSession(for operation: Operation) -> any ApolloURLSession { + MockURLSession(responseProvider: Self.self) + } + + func cacheInterceptor(for operation: Operation) -> any CacheInterceptor { + DefaultCacheInterceptor(store: ApolloStore(cache: NoCache())) + } + } + + // MARK: - Tests + + func testMaxRetryInterceptorErrorsAfterMaximumRetries() async throws { + let testProvider = TestProvider( + testInterceptor: BlindRetryingTestInterceptor(), + retryCount: 15 + ) let network = RequestChainNetworkTransport(interceptorProvider: testProvider, endpointURL: TestURL.mockServer.url) - - let expectation = self.expectation(description: "Request sent") - + let operation = MockQuery.mock() - _ = network.send(operation: operation) { result in - defer { - expectation.fulfill() - } - - switch result { - case .success: - XCTFail("This should not have worked") - case .failure(let error): - switch error { - case MaxRetryInterceptor.RetryError.hitMaxRetryCount(let count, let operationName): - XCTAssertEqual(count, testProvider.retryCount) - // There should be one more hit than retries since it will be hit on the original call - XCTAssertEqual(testProvider.testInterceptor.hitCount, testProvider.retryCount + 1) - XCTAssertEqual(operationName, MockQuery.operationName) - default: - XCTFail("Unexpected error type: \(error)") - } - } - } - - self.wait(for: [expectation], timeout: 1) + let results = try network.send(operation: operation, cachePolicy: .fetchIgnoringCacheCompletely) + + await expect { + var iterator = results.makeAsyncIterator() + _ = try await iterator.next() + + }.to( + throwError(errorType: MaxRetryInterceptor.MaxRetriesError.self) { error in + expect(error.count).to(equal(testProvider.retryCount + 1)) + expect(error.operationName).to(equal(MockQuery.operationName)) + }) } - func testRetryInterceptorDoesNotErrorIfRetriedFewerThanMaxTimes() { - class TestProvider: InterceptorProvider { - let testInterceptor = RetryToCountThenSucceedInterceptor(timesToCallRetry: 2) - let retryCount = 3 - - let mockClient: MockURLSessionClient = { - let client = MockURLSessionClient() - client.jsonData = [:] - client.response = HTTPURLResponse(url: TestURL.mockServer.url, - statusCode: 200, - httpVersion: nil, - headerFields: nil) - return client - }() - - func interceptors( - for operation: Operation - ) -> [any ApolloInterceptor] { - [ - MaxRetryInterceptor(maxRetriesAllowed: self.retryCount), - self.testInterceptor, - NetworkFetchInterceptor(client: self.mockClient), - JSONResponseParsingInterceptor() - ] - } + func testRetryInterceptorDoesNotErrorIfRetriedFewerThanMaxTimes() async throws { + let testInterceptor = RetryToCountThenSucceedInterceptor(timesToCallRetry: 2) + let testProvider = TestProvider( + testInterceptor: testInterceptor, + retryCount: 3 + ) + + await TestProvider.registerRequestHandler(for: TestURL.mockServer.url) { request in + return ( + HTTPURLResponse( + url: TestURL.mockServer.url, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )!, + Data() + ) } - let testProvider = TestProvider() let network = RequestChainNetworkTransport(interceptorProvider: testProvider, endpointURL: TestURL.mockServer.url) - - let expectation = self.expectation(description: "Request sent") - + let operation = MockQuery.mock() - _ = network.send(operation: operation) { result in - defer { - expectation.fulfill() - } - - switch result { - case .success: - XCTAssertEqual(testProvider.testInterceptor.timesRetryHasBeenCalled, testProvider.testInterceptor.timesToCallRetry) - case .failure(let error): - XCTFail("Unexpected error: \(error.localizedDescription)") - } + let results = try network.send(operation: operation, cachePolicy: .fetchIgnoringCacheCompletely) + + for try await result in results { + expect(testInterceptor.timesRetryHasBeenCalled).to(equal(testInterceptor.timesToCallRetry)) + return } - - self.wait(for: [expectation], timeout: 1) } } diff --git a/Tests/ApolloTests/MultipartFormData+Testing.swift b/Tests/ApolloTests/MultipartFormData+Testing.swift index b49fde370..0721bd888 100644 --- a/Tests/ApolloTests/MultipartFormData+Testing.swift +++ b/Tests/ApolloTests/MultipartFormData+Testing.swift @@ -7,8 +7,9 @@ extension MultipartFormData { let encodedData = try self.encode() let string = String(bytes: encodedData, encoding: .utf8)! + let crlf = String(data: MultipartResponseParsing.CRLF, encoding: .utf8)! // Replacing CRLF with new line as string literals uses new lines - return string.replacingOccurrences(of: MultipartFormData.CRLF, with: "\n") + return string.replacingOccurrences(of: crlf, with: "\n") } } diff --git a/Tests/ApolloTests/Network/ApolloURLSessionTests.swift b/Tests/ApolloTests/Network/ApolloURLSessionTests.swift new file mode 100644 index 000000000..f4caf328c --- /dev/null +++ b/Tests/ApolloTests/Network/ApolloURLSessionTests.swift @@ -0,0 +1,244 @@ +import XCTest +@testable import Apollo +import Nimble +import ApolloInternalTestHelpers + +class ApolloURLSessionTests: XCTestCase, MockResponseProvider { + + var session: MockURLSession! + var sessionConfiguration: URLSessionConfiguration! + + @MainActor + override func setUp() { + super.setUp() + + session = MockURLSession(responseProvider: Self.self) + } + + override func tearDown() async throws { + await Self.cleanUpRequestHandlers() + session = nil + sessionConfiguration = nil + + try await super.tearDown() + } + + private func request( + for url: URL, + responseData: Data?, + statusCode: Int, + httpVersion: String? = nil, + headerFields: [String: String]? = nil + ) async -> URLRequest { + let request = URLRequest( + url: url, + cachePolicy: .reloadIgnoringCacheData, + timeoutInterval: 10 + ) + + await Self.registerRequestHandler(for: url) { request in + guard let requestURL = request.url else { + throw URLError(.badURL) + } + + let response = HTTPURLResponse( + url: requestURL, + statusCode: statusCode, + httpVersion: httpVersion, + headerFields: headerFields + ) + + return (response!, responseData) + } + + return request + } + + func test__request__basicGet() async throws { + let url = URL(string: "http://www.test.com/basicget")! + let stringResponse = "Basic GET Response Data" + let request = await self.request( + for: url, + responseData: stringResponse.data(using: .utf8), + statusCode: 200 + ) + + let (dataStream, response) = try await self.session.bytes(for: request, delegate: nil) + + XCTAssertEqual(request.url, response.url) + XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 200) + + var chunkCount = 0 + for try await chunk in dataStream.chunks { + chunkCount += 1 + expect(chunk).toNot(beEmpty()) + expect(String(data: chunk, encoding: .utf8)).to(equal(stringResponse)) + } + + expect(chunkCount).to(equal(1)) + } + + func test__request__gettingImage() async throws { + let url = URL(string: "http://www.test.com/gettingImage")! + #if os(macOS) + let responseImg = NSImage(systemSymbolName: "pencil", accessibilityDescription: nil) + let responseData = responseImg?.tiffRepresentation + #else + guard let responseImg = UIImage(systemName: "pencil") else { + XCTFail("Failed to create UIImage from system name.") + return + } + let responseData = responseImg.pngData() + #endif + let headerFields = ["Content-Type": "image/jpeg"] + let request = await self.request( + for: url, + responseData: responseData, + statusCode: 200, + headerFields: headerFields + ) + + let (dataStream, response) = try await self.session.bytes(for: request, delegate: nil) + guard let httpResponse = response as? HTTPURLResponse else { + fail() + return + } + + XCTAssertEqual(httpResponse.allHeaderFields["Content-Type"] as! String, "image/jpeg") + XCTAssertEqual(request.url, httpResponse.url) + + var chunkCount = 0 + for try await chunk in dataStream.chunks { + chunkCount += 1 + expect(chunk).toNot(beEmpty()) + + #if os(macOS) + let image = NSImage(data: chunk) + XCTAssertNotNil(image) + #else + let image = UIImage(data: chunk) + XCTAssertNotNil(image) + #endif + } + + expect(chunkCount).to(equal(1)) + } + + func test__request__postingJSON() async throws { + let testJSON = ["key": "value"] + let data = try JSONSerialization.data(withJSONObject: testJSON, options: .prettyPrinted) + let url = URL(string: "http://www.test.com/postingJSON")! + let headerFields = ["Content-Type": "application/json"] + + var request = await self.request( + for: url, + responseData: data, + statusCode: 200, + headerFields: headerFields + ) + request.httpBody = data + request.httpMethod = GraphQLHTTPMethod.POST.rawValue + + let (dataStream, response) = try await self.session.bytes(for: request, delegate: nil) + guard let httpResponse = response as? HTTPURLResponse else { + fail() + return + } + + XCTAssertEqual(request.url, httpResponse.url) + + var chunkCount = 0 + for try await chunk in dataStream.chunks { + chunkCount += 1 + let parsedJSON = try JSONSerialization.jsonObject(with: chunk) as! [String : String] + XCTAssertEqual(parsedJSON, testJSON) + } + + expect(chunkCount).to(equal(1)) + } + + func test__request__cancellingTaskDirectly_shouldThrowCancellationError() async throws { + let url = URL(string: "http://www.test.com/cancelTaskDirectly")! + let request = await request( + for: url, + responseData: nil, + statusCode: -1 + ) + + let task = Task { + await expect { + try await self.session.bytes(for: request, delegate: nil) + }.to(throwError(CancellationError())) + } + + task.cancel() + + let expectation = await task.value + expect(expectation.status).to(equal(.passed)) + } + + func test__request__multipleSimultaneousRequests() async throws { + let expectation = self.expectation(description: "request sent, response received") + let iterations = 20 + expectation.expectedFulfillmentCount = iterations + @Atomic var taskIDs: [Int] = [] + + var responseStrings = [Int: String]() + var requests = [Int: URLRequest]() + + for i in 0.. URLRequest { + let request = URLRequest( + url: url, + cachePolicy: .reloadIgnoringCacheData, + timeoutInterval: 10 + ) + + await Self.registerRequestHandler(for: url) { request in + guard let requestURL = request.url else { + throw URLError(.badURL) + } + + let response = HTTPURLResponse( + url: requestURL, + statusCode: statusCode, + httpVersion: httpVersion, + headerFields: headerFields + ) + + return (response!, responseData) + } + + return request + } + + func test__multipartResponse__givenSingleChunk_shouldReturnSingleChunk() async throws { + let url = URL(string: "http://www.test.com/multipart-single-chunk")! + let boundary = "-" + let multipartString = "--\(boundary)\r\nContent-Type: application/json\r\n\r\n{\"data\": {\"field1\": \"value1\"}}\r\n--\(boundary)--" + + let request = await self.request( + for: url, + responseData: multipartString.data(using: .utf8), + statusCode: 200, + headerFields: ["Content-Type": "multipart/mixed; boundary=\(boundary)"] + ) + + let (dataStream, response) = try await self.session.bytes(for: request, delegate: nil) + guard let httpResponse = response as? HTTPURLResponse else { + fail() + return + } + + XCTAssertEqual(httpResponse.url, request.url) + XCTAssertTrue(httpResponse.isSuccessful) + XCTAssertTrue(httpResponse.isMultipart) + + var chunkCount = 0 + for try await chunk in dataStream.chunks { + chunkCount += 1 + let chunkString = String(data: chunk, encoding: .utf8) + switch chunkCount { + case 1: + expect(chunkString).to(equal(multipartString)) + case 2: + // "" is the second result and is expected to be empty + expect(chunkString).to(equal("")) + default: + fail("unexpected chunk received: \(chunkString ?? "")") + } + } + + // Results are sent twice for multipart responses with both received here because this test infrastructure uses + // URLSessionClient directly whereas in a request chain the interceptors may handle data differently. + // + // 1. When multipart chunks is received, to be processed immediately + // 2. When the operation completes, with any remaining task data + expect(chunkCount).to(equal(2)) + } + + func test__multipart__givenMultipleChunks_recievedAtTheSameTime_shouldReturnAllChunks() async throws { + let url = URL(string: "http://www.test.com/multipart-many-chunks")! + let boundary = "-" + let multipartString = "--\(boundary)\r\nContent-Type: application/json\r\n\r\n{\"data\": {\"field1\": \"value1\"}}\r\n--\(boundary)\r\nContent-Type: application/json\r\n\r\n{\"data\": {\"field2\": \"value2\"}}\r\n--\(boundary)--" + + let request = await self.request( + for: url, + responseData: multipartString.data(using: .utf8), + statusCode: 200, + headerFields: ["Content-Type": "multipart/mixed; boundary=\(boundary)"] + ) + + let (dataStream, response) = try await self.session.bytes(for: request, delegate: nil) + guard let httpResponse = response as? HTTPURLResponse else { + fail() + return + } + + XCTAssertEqual(httpResponse.url, request.url) + XCTAssertTrue(httpResponse.isSuccessful) + XCTAssertTrue(httpResponse.isMultipart) + + var chunkCount = 0 + for try await chunk in dataStream.chunks { + chunkCount += 1 + let chunkString = String(data: chunk, encoding: .utf8) + switch chunkCount { + case 1: + let expected = "--\(boundary)\r\nContent-Type: application/json\r\n\r\n{\"data\": {\"field1\": \"value1\"}}\r\n" + expect(chunkString).to(equal(expected)) + case 2: + let expected = "--\(boundary)\r\nContent-Type: application/json\r\n\r\n{\"data\": {\"field2\": \"value2\"}}\r\n" + expect(chunkString).to(equal(expected)) + case 3: + // "" is the final result and is expected to be empty + expect(chunkString).to(equal("")) + default: + fail("unexpected chunk received: \(chunkString ?? "")") + } + } + + // Results are sent twice for multipart responses with both received here because this test infrastructure uses + // URLSessionClient directly whereas in a request chain the interceptors may handle data differently. + // + // 1. When multipart chunks is received, to be processed immediately + // 2. When the operation completes, with any remaining task data + expect(chunkCount).to(equal(3)) + } + + func test__multipart__givenCompleteAndPartialChunks_shouldReturnCompleteChunkSeparateFromPartialChunk() async throws { + let url = URL(string: "http://www.test.com/multipart-complete-and-partial-chunk")! + let boundary = "-" + let completeChunk = "--\(boundary)\r\nContent-Type: application/json\r\n\r\n{\"data\": {\"field1\": \"value1\"}}" + let partialChunk = "\r\n--\(boundary)\r\nConte" + let multipartString = completeChunk + partialChunk + + let request = await self.request( + for: url, + responseData: multipartString.data(using: .utf8), + statusCode: 200, + headerFields: ["Content-Type": "multipart/mixed; boundary=\(boundary)"] + ) + + let (dataStream, response) = try await self.session.bytes(for: request, delegate: nil) + guard let httpResponse = response as? HTTPURLResponse else { + fail() + return + } + + XCTAssertEqual(httpResponse.url, request.url) + XCTAssertTrue(httpResponse.isSuccessful) + XCTAssertTrue(httpResponse.isMultipart) + + var chunkCount = 0 + for try await chunk in dataStream.chunks { + chunkCount += 1 + let chunkString = String(data: chunk, encoding: .utf8) + switch chunkCount { + case 1: + expect(chunkString).to(equal(completeChunk)) + case 2: + expect(chunkString).to(equal(partialChunk)) + default: + fail("unexpected chunk received: \(chunkString ?? "")") + } + } + + // Results are sent twice for multipart responses with both received here because this test infrastructure uses + // URLSessionClient directly whereas in a request chain the interceptors may handle data differently. + // + // 1. When multipart chunks is received, to be processed immediately + // 2. When the operation completes, with any remaining task data + expect(chunkCount).to(equal(2)) + } + + func test__multipart__givenChunkContainingBoundaryString_shouldNotSplitChunk() async throws { + let url = URL(string: "http://www.test.com/multipart-containing-boundary-string")! + let boundary = "-" + let multipartString = "--\(boundary)\r\nContent-Type: application/json\r\n\r\n{\"data\": {\"field1\": \"value1--\(boundary)\"}}\r\n--\(boundary)--" + + let request = await self.request( + for: url, + responseData: multipartString.data(using: .utf8), + statusCode: 200, + headerFields: ["Content-Type": "multipart/mixed; boundary=\(boundary)"] + ) + + let (dataStream, response) = try await self.session.bytes(for: request, delegate: nil) + guard let httpResponse = response as? HTTPURLResponse else { + fail() + return + } + + XCTAssertEqual(httpResponse.url, request.url) + XCTAssertTrue(httpResponse.isSuccessful) + XCTAssertTrue(httpResponse.isMultipart) + + var chunkCount = 0 + for try await chunk in dataStream.chunks { + chunkCount += 1 + let chunkString = String(data: chunk, encoding: .utf8) + switch chunkCount { + case 1: + expect(chunkString).to(equal(multipartString)) + case 2: + // "" is the second result and is expected to be empty + expect(chunkString).to(equal("")) + default: + fail("unexpected chunk received: \(chunkString ?? "")") + } + } + + // Results are sent twice for multipart responses with both received here because this test infrastructure uses + // URLSessionClient directly whereas in a request chain the interceptors may handle data differently. + // + // 1. When multipart chunks is received, to be processed immediately + // 2. When the operation completes, with any remaining task data + expect(chunkCount).to(equal(2)) + } + + func test__multipart__givenChunkContainingBoundaryStringWithoutClosingBoundary_shouldNotSplitChunk() async throws { + let url = URL(string: "http://www.test.com/multipart-without-closing-boundary")! + let boundary = "-" + let multipartString = "--\(boundary)\r\nContent-Type: application/json\r\n\r\n{\"data\": {\"field1\": \"value1--\(boundary)\"}}" + + let request = await self.request( + for: url, + responseData: multipartString.data(using: .utf8), + statusCode: 200, + headerFields: ["Content-Type": "multipart/mixed; boundary=\(boundary)"] + ) + + let (dataStream, response) = try await self.session.bytes(for: request, delegate: nil) + guard let httpResponse = response as? HTTPURLResponse else { + fail() + return + } + + XCTAssertEqual(httpResponse.url, request.url) + XCTAssertTrue(httpResponse.isSuccessful) + XCTAssertTrue(httpResponse.isMultipart) + + var chunkCount = 0 + for try await chunk in dataStream.chunks { + chunkCount += 1 + let chunkString = String(data: chunk, encoding: .utf8) + switch chunkCount { + case 1: + expect(chunkString).to(equal(multipartString)) + default: + fail("unexpected chunk received: \(chunkString ?? "")") + } + } + + // Results are sent twice for multipart responses with both received here because this test infrastructure uses + // URLSessionClient directly whereas in a request chain the interceptors may handle data differently. + // + // 1. When multipart chunks is received, to be processed immediately + // 2. When the operation completes, with any remaining task data + expect(chunkCount).to(equal(1)) + } +} diff --git a/Tests/ApolloTests/Network/URLSessionClientTests.swift b/Tests/ApolloTests/Network/URLSessionClientTests.swift deleted file mode 100644 index fccdbc41f..000000000 --- a/Tests/ApolloTests/Network/URLSessionClientTests.swift +++ /dev/null @@ -1,595 +0,0 @@ -import XCTest -@testable import Apollo -import ApolloInternalTestHelpers - -class URLSessionClientTests: XCTestCase { - - var client: URLSessionClient! - var sessionConfiguration: URLSessionConfiguration! - - @MainActor - override func setUp() { - super.setUp() - - Self.testObserver.start() - - sessionConfiguration = URLSessionConfiguration.default - sessionConfiguration.protocolClasses = [MockURLProtocol.self] - client = URLSessionClient(sessionConfiguration: sessionConfiguration) - } - - override func tearDown() { - client = nil - sessionConfiguration = nil - - super.tearDown() - } - - private func request( - for url: URL, - responseData: Data?, - statusCode: Int, - httpVersion: String? = nil, - headerFields: [String: String]? = nil - ) -> URLRequest { - let request = URLRequest( - url: url, - cachePolicy: .reloadIgnoringCacheData, - timeoutInterval: 10 - ) - - Self.requestHandlers[url] = { request in - guard let requestURL = request.url else { - throw URLError(.badURL) - } - - let response = HTTPURLResponse( - url: requestURL, - statusCode: statusCode, - httpVersion: httpVersion, - headerFields: headerFields - ) - - return .success((response!, responseData)) - } - - return request - } - - func test__request__basicGet() { - let url = URL(string: "http://www.test.com/basicget")! - let stringResponse = "Basic GET Response Data" - let request = self.request( - for: url, - responseData: stringResponse.data(using: .utf8), - statusCode: 200 - ) - - let expectation = self.expectation(description: "Basic GET request completed") - - self.client.sendRequest(request) { result in - defer { - expectation.fulfill() - } - - switch result { - case .failure(let error): - XCTFail("Unexpected error: \(error)") - - case .success(let (data, httpResponse)): - XCTAssertFalse(data.isEmpty) - XCTAssertEqual(String(data: data, encoding: .utf8), stringResponse) - XCTAssertEqual(request.url, httpResponse.url) - XCTAssertEqual(httpResponse.statusCode, 200) - } - } - - self.wait(for: [expectation], timeout: 5) - } - - func test__request__gettingImage() { - let url = URL(string: "http://www.test.com/gettingImage")! - #if os(macOS) - let responseImg = NSImage(systemSymbolName: "pencil", accessibilityDescription: nil) - let responseData = responseImg?.tiffRepresentation - #else - guard let responseImg = UIImage(systemName: "pencil") else { - XCTFail("Failed to create UIImage from system name.") - return - } - let responseData = responseImg.pngData() - #endif - let headerFields = ["Content-Type": "image/jpeg"] - let request = self.request( - for: url, - responseData: responseData, - statusCode: 200, - headerFields: headerFields - ) - - let expectation = self.expectation(description: "GET request for image completed") - - self.client.sendRequest(request) { result in - defer { - expectation.fulfill() - } - - switch result { - case .failure(let error): - XCTFail("Unexpected error: \(error)") - - case .success(let (data, httpResponse)): - XCTAssertFalse(data.isEmpty) - XCTAssertEqual(httpResponse.allHeaderFields["Content-Type"] as! String, "image/jpeg") - #if os(macOS) - let image = NSImage(data: data) - XCTAssertNotNil(image) - #else - let image = UIImage(data: data) - XCTAssertNotNil(image) - #endif - XCTAssertEqual(request.url, httpResponse.url) - } - } - - self.wait(for: [expectation], timeout: 5) - } - - func test__request__postingJSON() throws { - let testJSON = ["key": "value"] - let data = try JSONSerialization.data(withJSONObject: testJSON, options: .prettyPrinted) - let url = URL(string: "http://www.test.com/postingJSON")! - let headerFields = ["Content-Type": "application/json"] - - var request = self.request( - for: url, - responseData: data, - statusCode: 200, - headerFields: headerFields - ) - request.httpBody = data - request.httpMethod = GraphQLHTTPMethod.POST.rawValue - - let expectation = self.expectation(description: "POST request with JSON completed") - - self.client.sendRequest(request) { result in - defer { - expectation.fulfill() - } - - switch result { - case .failure(let error): - XCTFail("Unexpected error: \(error)") - - case .success(let (data, httpResponse)): - XCTAssertEqual(request.url, httpResponse.url) - - do { - let parsedJSON = try JSONSerialization.jsonObject(with: data) as! [String : String] - XCTAssertEqual(parsedJSON, testJSON) - } catch { - XCTFail("Unexpected error: \(error)") - } - } - } - - self.wait(for: [expectation], timeout: 5) - } - - func test__request__cancellingTaskDirectly_shouldCallCompletionWithError() throws { - let url = URL(string: "http://www.test.com/cancelTaskDirectly")! - let request = request( - for: url, - responseData: nil, - statusCode: -1 - ) - - let expectation = self.expectation(description: "Cancelled task completed") - - let task = self.client.sendRequest(request) { result in - defer { - expectation.fulfill() - } - - switch result { - case .failure(let error): - switch error { - case URLSessionClient.URLSessionClientError.networkError(let data, let httpResponse, let underlying): - XCTAssertTrue(data.isEmpty) - XCTAssertNil(httpResponse) - let nsError = underlying as NSError - XCTAssertEqual(nsError.domain, NSURLErrorDomain) - XCTAssertEqual(nsError.code, NSURLErrorCancelled) - - default: - XCTFail("Unexpected error: \(error)") - } - - case .success: - XCTFail("Task succeeded when it should have been cancelled!") - } - } - - task.cancel() - - self.wait(for: [expectation], timeout: 5) - } - - func test__request__cancellingTaskThroughClient_shouldNotCallCompletion() throws { - let url = URL(string: "http://www.test.com/cancelThroughClient")! - let request = request( - for: url, - responseData: nil, - statusCode: -1 - ) - - let expectation = self.expectation(description: "Cancelled task completed") - expectation.isInverted = true - - let task = self.client.sendRequest(request) { result in - // This shouldn't get hit since we cancel the task immediately - expectation.fulfill() - } - - self.client.cancel(task: task) - - // Instead of waiting an arbitrary amount of time for the completion to maybe be called - // the following mimics what Apple's documentation for URLSessionTask.cancel() states - // happens when a task is cancelled, i.e.: manually calling the delegate method - // urlSession(_:task:didCompleteWithError:) - self.client.urlSession(self.client.session, task: task, didCompleteWithError: NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled)) - - self.wait(for: [expectation], timeout: 0.5) - - } - - func test__request__multipleSimultaneousRequests() { - let expectation = self.expectation(description: "request sent, response received") - let iterations = 20 - expectation.expectedFulfillmentCount = iterations - @Atomic var taskIDs: [Int] = [] - - var responseStrings = [Int: String]() - var requests = [Int: URLRequest]() - for i in 0..( - chain: any RequestChain, - request: HTTPRequest, - response: HTTPResponse?, - completion: @escaping @Sendable (Result, any Error>) -> Void) -} diff --git a/apollo-ios/Sources/Apollo/ApolloURLSession.swift b/apollo-ios/Sources/Apollo/ApolloURLSession.swift new file mode 100644 index 000000000..83f054434 --- /dev/null +++ b/apollo-ios/Sources/Apollo/ApolloURLSession.swift @@ -0,0 +1,15 @@ +import Foundation + +public protocol ApolloURLSession: Sendable { + func chunks(for request: some GraphQLRequest) async throws -> (any AsyncChunkSequence, URLResponse) + + func invalidateAndCancel() +} + +extension URLSession: ApolloURLSession { + public func chunks(for request: some GraphQLRequest) async throws -> (any AsyncChunkSequence, URLResponse) { + try Task.checkCancellation() + let (bytes, response) = try await bytes(for: request.toURLRequest(), delegate: nil) + return (bytes.chunks, response) + } +} diff --git a/apollo-ios/Sources/Apollo/AsyncHTTPResponseChunkSequence.swift b/apollo-ios/Sources/Apollo/AsyncHTTPResponseChunkSequence.swift new file mode 100644 index 000000000..b39de54fd --- /dev/null +++ b/apollo-ios/Sources/Apollo/AsyncHTTPResponseChunkSequence.swift @@ -0,0 +1,94 @@ +import Foundation + +extension URLSession.AsyncBytes { + + var chunks: AsyncHTTPResponseChunkSequence { + return AsyncHTTPResponseChunkSequence(self) + } + +} + +public protocol AsyncChunkSequence: AsyncSequence where Element == Data { + +} + +/// An `AsyncSequence` of multipart reponse chunks. This sequence wraps a `URLSession.AsyncBytes` +/// sequence. It uses the multipart boundary specified by the `HTTPURLResponse` to split the data +/// into chunks as it is received. +public struct AsyncHTTPResponseChunkSequence: AsyncChunkSequence { + public typealias Element = Data + + private let bytes: URLSession.AsyncBytes + + init(_ bytes: URLSession.AsyncBytes) { + self.bytes = bytes + } + + public func makeAsyncIterator() -> AsyncIterator { + return AsyncIterator(bytes.makeAsyncIterator(), boundary: chunkBoundary) + } + + private var chunkBoundary: String? { + guard let response = bytes.task.response as? HTTPURLResponse else { + return nil + } + + return response.multipartHeaderComponents.boundary + } + + public struct AsyncIterator: AsyncIteratorProtocol { + public typealias Element = Data + + private var underlyingIterator: URLSession.AsyncBytes.AsyncIterator + + private let boundary: Data? + + private typealias Constants = MultipartResponseParsing + + init( + _ underlyingIterator: URLSession.AsyncBytes.AsyncIterator, + boundary: String? + ) { + self.underlyingIterator = underlyingIterator + + if let boundaryString = boundary?.data(using: .utf8) { + self.boundary = Constants.Delimeter + boundaryString + } else { + self.boundary = nil + } + } + + public mutating func next() async throws -> Data? { + var buffer = Data() + + while let next = try await self.underlyingIterator.next() { + buffer.append(next) + + if let boundary, + let boundaryRange = buffer.range(of: boundary, options: [.anchored, .backwards]) { + buffer.removeSubrange(boundaryRange) + + formatAsChunk(&buffer) + + if !buffer.isEmpty { + return buffer + } + } + } + + formatAsChunk(&buffer) + + return buffer.isEmpty ? nil : buffer + } + + private func formatAsChunk(_ buffer: inout Data) { + if buffer.prefix(Constants.CRLF.count) == Constants.CRLF { + buffer.removeFirst(Constants.CRLF.count) + } + + if buffer == Constants.CloseDelimeter { + buffer.removeAll() + } + } + } +} diff --git a/apollo-ios/Sources/Apollo/AutoPersistedQueryConfiguration.swift b/apollo-ios/Sources/Apollo/AutoPersistedQueryConfiguration.swift new file mode 100644 index 000000000..bce1f011c --- /dev/null +++ b/apollo-ios/Sources/Apollo/AutoPersistedQueryConfiguration.swift @@ -0,0 +1,44 @@ +/// A configuration struct used by a `GraphQLRequest` to configure the usage of +/// [Automatic Persisted Queries (APQs).](https://www.apollographql.com/docs/apollo-server/performance/apq) +/// +/// APQs are a feature of Apollo Server and the Apollo GraphOS Router. +/// When using Apollo iOS to connect to any other GraphQL server, setting `autoPersistQueries` to +/// `true` will result in unintended network errors. +public struct AutoPersistedQueryConfiguration: Sendable, Hashable { + /// Indicates if Auto Persisted Queries should be used for the request. Defaults to `false`. + public var autoPersistQueries: Bool + + /// `true` if when an Auto Persisted query is retried, it should use `GET` instead of `POST` to + /// send the query. Defaults to `false`. + public var useGETForPersistedQueryRetry: Bool + + /// - Parameters: + /// - autoPersistQueries: `true` if Auto Persisted Queries should be used. Defaults to `false`. + /// - useGETForPersistedQueryRetry: `true` if when an Auto-Persisted query is retried, it should use `GET` instead of `POST` to send the query. Defaults to `false`. + public init( + autoPersistQueries: Bool = false, + useGETForPersistedQueryRetry: Bool = false + ) { + self.autoPersistQueries = autoPersistQueries + self.useGETForPersistedQueryRetry = useGETForPersistedQueryRetry + } +} + +public protocol AutoPersistedQueryCompatibleRequest: GraphQLRequest { + + /// A configuration struct used by a `GraphQLRequest` to configure the usage of + /// [Automatic Persisted Queries (APQs).](https://www.apollographql.com/docs/apollo-server/performance/apq) + /// By default, APQs are disabled. + var apqConfig: AutoPersistedQueryConfiguration { get set } + + /// Flag used to track the state of the Auto Persisted Query request. Should default to `false`. + /// + /// If the request containing this config has already received a network response indicating that + /// the persisted query id was not recognized, the `AutomaticPersistedQueryInterceptor` will set + /// this to `true` and then invoke a retry of the request. + /// + /// If this is set to `false`, the requests should include only the persisted query operation + /// identifier. If `true`, the request should also include the query body to register with the + /// server as a persisted query. + var isPersistedQueryRetry: Bool { get set} +} diff --git a/apollo-ios/Sources/Apollo/AutomaticPersistedQueryInterceptor.swift b/apollo-ios/Sources/Apollo/AutomaticPersistedQueryInterceptor.swift deleted file mode 100644 index 08d8b0760..000000000 --- a/apollo-ios/Sources/Apollo/AutomaticPersistedQueryInterceptor.swift +++ /dev/null @@ -1,111 +0,0 @@ -import Foundation -#if !COCOAPODS -import ApolloAPI -#endif - -public struct AutomaticPersistedQueryInterceptor: ApolloInterceptor { - - public enum APQError: LocalizedError, Equatable { - case noParsedResponse - case persistedQueryNotFoundForPersistedOnlyQuery(operationName: String) - case persistedQueryRetryFailed(operationName: String) - - public var errorDescription: String? { - switch self { - case .noParsedResponse: - return "The Automatic Persisted Query Interceptor was called before a response was received. Double-check the order of your interceptors." - case .persistedQueryRetryFailed(let operationName): - return "Persisted query retry failed for operation \"\(operationName)\"." - - case .persistedQueryNotFoundForPersistedOnlyQuery(let operationName): - return "The Persisted Query for operation \"\(operationName)\" was not found. The operation is a `.persistedOnly` operation and cannot be automatically persisted if it is not recognized by the server." - - } - } - } - - public var id: String = UUID().uuidString - - /// Designated initializer - public init() {} - - public func interceptAsync( - chain: any RequestChain, - request: HTTPRequest, - response: HTTPResponse?, - completion: @escaping GraphQLResultHandler - ) { - guard let jsonRequest = request as? JSONRequest, - jsonRequest.autoPersistQueries else { - // Not a request that handles APQs, continue along - chain.proceedAsync( - request: request, - response: response, - interceptor: self, - completion: completion - ) - return - } - - guard let result = response?.parsedResponse else { - // This is in the wrong order - this needs to be parsed before we can check it. - chain.handleErrorAsync( - APQError.noParsedResponse, - request: request, - response: response, - completion: completion - ) - return - } - - guard let errors = result.errors else { - // No errors were returned so no retry is necessary, continue along. - chain.proceedAsync( - request: request, - response: response, - interceptor: self, - completion: completion - ) - return - } - - let errorMessages = errors.compactMap { $0.message } - guard errorMessages.contains("PersistedQueryNotFound") else { - // The errors were not APQ errors, continue along. - chain.proceedAsync( - request: request, - response: response, - interceptor: self, - completion: completion - ) - return - } - - guard !jsonRequest.isPersistedQueryRetry else { - // We already retried this and it didn't work. - chain.handleErrorAsync( - APQError.persistedQueryRetryFailed(operationName: Operation.operationName), - request: jsonRequest, - response: response, - completion: completion - ) - - return - } - - if Operation.operationDocument.definition == nil { - chain.handleErrorAsync( - APQError.persistedQueryNotFoundForPersistedOnlyQuery(operationName: Operation.operationName), - request: jsonRequest, - response: response, - completion: completion - ) - - return - } - - // We need to retry this query with the full body. - jsonRequest.isPersistedQueryRetry = true - chain.retry(request: jsonRequest, completion: completion) - } -} diff --git a/apollo-ios/Sources/Apollo/CacheReadInterceptor.swift b/apollo-ios/Sources/Apollo/CacheReadInterceptor.swift deleted file mode 100644 index d8f270543..000000000 --- a/apollo-ios/Sources/Apollo/CacheReadInterceptor.swift +++ /dev/null @@ -1,129 +0,0 @@ -import Foundation -#if !COCOAPODS -import ApolloAPI -#endif - -/// An interceptor that reads data from the cache for queries, following the `HTTPRequest`'s `cachePolicy`. -public struct CacheReadInterceptor: ApolloInterceptor { - - private let store: ApolloStore - - public var id: String = UUID().uuidString - - /// Designated initializer - /// - /// - Parameter store: The store to use when reading from the cache. - public init(store: ApolloStore) { - self.store = store - } - - public func interceptAsync( - chain: any RequestChain, - request: HTTPRequest, - response: HTTPResponse?, - completion: @escaping @Sendable (Result, any Error>) -> Void) { - - switch Operation.operationType { - case .mutation, - .subscription: - // Mutations and subscriptions don't need to hit the cache. - chain.proceedAsync( - request: request, - response: response, - interceptor: self, - completion: completion - ) - - case .query: - switch request.cachePolicy { - case .fetchIgnoringCacheCompletely, - .fetchIgnoringCacheData: - // Don't bother with the cache, just keep going - chain.proceedAsync( - request: request, - response: response, - interceptor: self, - completion: completion - ) - - case .returnCacheDataAndFetch: - self.fetchFromCache(for: request, chain: chain) { cacheFetchResult in - switch cacheFetchResult { - case .failure: - // Don't return a cache miss error, just keep going - break - case .success(let graphQLResult): - chain.returnValueAsync( - for: request, - value: graphQLResult, - completion: completion - ) - } - - // In either case, keep going asynchronously - chain.proceedAsync( - request: request, - response: response, - interceptor: self, - completion: completion - ) - } - case .returnCacheDataElseFetch: - self.fetchFromCache(for: request, chain: chain) { cacheFetchResult in - switch cacheFetchResult { - case .failure: - // Cache miss, proceed to network without returning error - chain.proceedAsync( - request: request, - response: response, - interceptor: self, - completion: completion - ) - - case .success(let graphQLResult): - // Cache hit! We're done. - chain.returnValueAsync( - for: request, - value: graphQLResult, - completion: completion - ) - } - } - case .returnCacheDataDontFetch: - self.fetchFromCache(for: request, chain: chain) { cacheFetchResult in - switch cacheFetchResult { - case .failure(let error): - // Cache miss - don't hit the network, just return the error. - chain.handleErrorAsync( - error, - request: request, - response: response, - completion: completion - ) - - case .success(let result): - chain.returnValueAsync( - for: request, - value: result, - completion: completion - ) - } - } - } - } - } - - private func fetchFromCache( - for request: HTTPRequest, - chain: any RequestChain, - completion: @escaping @Sendable (Result, any Error>) -> Void) { - - self.store.load(request.operation) { loadResult in - guard !chain.isCancelled else { - return - } - - completion(loadResult) - } - } -} diff --git a/apollo-ios/Sources/Apollo/CacheWriteInterceptor.swift b/apollo-ios/Sources/Apollo/CacheWriteInterceptor.swift deleted file mode 100644 index afb002368..000000000 --- a/apollo-ios/Sources/Apollo/CacheWriteInterceptor.swift +++ /dev/null @@ -1,72 +0,0 @@ -import Foundation -#if !COCOAPODS -import ApolloAPI -#endif - -/// An interceptor which writes data to the cache, following the `HTTPRequest`'s `cachePolicy`. -public struct CacheWriteInterceptor: ApolloInterceptor { - - public enum CacheWriteError: Error, LocalizedError { - case noResponseToParse - - public var errorDescription: String? { - switch self { - case .noResponseToParse: - return "The Cache Write Interceptor was called before a response was received to be parsed. Double-check the order of your interceptors." - } - } - } - - public let store: ApolloStore - public var id: String = UUID().uuidString - - /// Designated initializer - /// - /// - Parameter store: The store to use when writing to the cache. - public init(store: ApolloStore) { - self.store = store - } - - public func interceptAsync( - chain: any RequestChain, - request: HTTPRequest, - response: HTTPResponse?, - completion: @escaping GraphQLResultHandler - ) { - guard !chain.isCancelled else { - return - } - - guard request.cachePolicy != .fetchIgnoringCacheCompletely else { - // If we're ignoring the cache completely, we're not writing to it. - chain.proceedAsync( - request: request, - response: response, - interceptor: self, - completion: completion - ) - return - } - - guard let createdResponse = response else { - chain.handleErrorAsync( - CacheWriteError.noResponseToParse, - request: request, - response: response, - completion: completion - ) - return - } - - if let cacheRecords = createdResponse.cacheRecords { - self.store.publish(records: cacheRecords, identifier: request.contextIdentifier) - } - - chain.proceedAsync( - request: request, - response: createdResponse, - interceptor: self, - completion: completion - ) - } -} diff --git a/apollo-ios/Sources/Apollo/Cancellable.swift b/apollo-ios/Sources/Apollo/Cancellable.swift index cba4084c2..e89377de5 100644 --- a/apollo-ios/Sources/Apollo/Cancellable.swift +++ b/apollo-ios/Sources/Apollo/Cancellable.swift @@ -1,6 +1,7 @@ import Foundation /// An object that can be used to cancel an in progress action. +@available(*, deprecated) public protocol Cancellable: AnyObject, Sendable { /// Cancel an in progress action. func cancel() @@ -8,11 +9,13 @@ public protocol Cancellable: AnyObject, Sendable { // MARK: - URL Session Conformance +@available(*, deprecated) extension URLSessionTask: Cancellable {} // MARK: - Early-Exit Helper /// A class to return when we need to bail out of something which still needs to return `Cancellable`. +@available(*, deprecated) public final class EmptyCancellable: Cancellable { // Needs to be public so this can be instantiated outside of the current framework. @@ -23,7 +26,26 @@ public final class EmptyCancellable: Cancellable { } } +// MARK: - Task Conformance +#warning("Test that this works. Task is a struct, not a class.") +@available(*, deprecated) +public final class TaskCancellable: Cancellable { + + let task: Task + + init(task: Task) { + self.task = task + } + + public func cancel() { + task.cancel() + } +} + +// MARK: - CancellationState + +@available(*, deprecated) public class CancellationState: Cancellable, @unchecked Sendable { @Atomic var isCancelled: Bool = false diff --git a/apollo-ios/Sources/Apollo/DefaultInterceptorProvider.swift b/apollo-ios/Sources/Apollo/DefaultInterceptorProvider.swift deleted file mode 100644 index fecfd327c..000000000 --- a/apollo-ios/Sources/Apollo/DefaultInterceptorProvider.swift +++ /dev/null @@ -1,59 +0,0 @@ -#if !COCOAPODS -import ApolloAPI -#endif - -/// The default interceptor provider for typescript-generated code -open class DefaultInterceptorProvider: InterceptorProvider { - - private let client: URLSessionClient - private let store: ApolloStore - private let shouldInvalidateClientOnDeinit: Bool - - /// Designated initializer - /// - /// - Parameters: - /// - client: The `URLSessionClient` to use. Defaults to the default setup. - /// - shouldInvalidateClientOnDeinit: If the passed-in client should be invalidated when this interceptor provider is deinitialized. If you are recreating the `URLSessionClient` every time you create a new provider, you should do this to prevent memory leaks. Defaults to true, since by default we provide a `URLSessionClient` to new instances. - /// - store: The `ApolloStore` to use when reading from or writing to the cache. Make sure you pass the same store to the `ApolloClient` instance you're planning to use. - public init(client: URLSessionClient = URLSessionClient(), - shouldInvalidateClientOnDeinit: Bool = true, - store: ApolloStore) { - self.client = client - self.shouldInvalidateClientOnDeinit = shouldInvalidateClientOnDeinit - self.store = store - } - - deinit { - if self.shouldInvalidateClientOnDeinit { - self.client.invalidate() - } - } - - open func interceptors( - for operation: Operation - ) -> [any ApolloInterceptor] { - return [ - MaxRetryInterceptor(), - CacheReadInterceptor(store: self.store), - NetworkFetchInterceptor(client: self.client), - ResponseCodeInterceptor(), - MultipartResponseParsingInterceptor(), - jsonParsingInterceptor(for: operation), - AutomaticPersistedQueryInterceptor(), - CacheWriteInterceptor(store: self.store), - ] - } - - private func jsonParsingInterceptor(for operation: Operation) -> any ApolloInterceptor { - if Operation.hasDeferredFragments { - return IncrementalJSONResponseParsingInterceptor() - - } else { - return JSONResponseParsingInterceptor() - } - } - - open func additionalErrorInterceptor(for operation: Operation) -> (any ApolloErrorInterceptor)? { - return nil - } -} diff --git a/apollo-ios/Sources/Apollo/DispatchQueue+Optional.swift b/apollo-ios/Sources/Apollo/DispatchQueue+Optional.swift index 361947058..9b9a00e16 100644 --- a/apollo-ios/Sources/Apollo/DispatchQueue+Optional.swift +++ b/apollo-ios/Sources/Apollo/DispatchQueue+Optional.swift @@ -22,7 +22,8 @@ extension DispatchQueue { static func returnResultAsyncIfNeeded( on callbackQueue: DispatchQueue?, action: (@Sendable (Result) -> Void)?, - result: Result) { + result: Result + ) { if let action = action { self.performAsyncIfNeeded(on: callbackQueue) { action(result) diff --git a/apollo-ios/Sources/Apollo/GraphQLFile.swift b/apollo-ios/Sources/Apollo/GraphQLFile.swift index 999ddf18b..02cc5723b 100644 --- a/apollo-ios/Sources/Apollo/GraphQLFile.swift +++ b/apollo-ios/Sources/Apollo/GraphQLFile.swift @@ -1,7 +1,7 @@ import Foundation /// A file which can be uploaded to a GraphQL server -public struct GraphQLFile: Hashable { +public struct GraphQLFile: Sendable, Hashable { public let fieldName: String public let originalName: String public let mimeType: String diff --git a/apollo-ios/Sources/Apollo/GraphQLRequest.swift b/apollo-ios/Sources/Apollo/GraphQLRequest.swift new file mode 100644 index 000000000..9aac09868 --- /dev/null +++ b/apollo-ios/Sources/Apollo/GraphQLRequest.swift @@ -0,0 +1,126 @@ +import Foundation +#if !COCOAPODS +import ApolloAPI +#endif + +public protocol GraphQLRequest: Sendable { + associatedtype Operation: GraphQLOperation + + /// The endpoint to make a GraphQL request to + var graphQLEndpoint: URL { get set } + + /// The GraphQL Operation to execute + var operation: Operation { get set } + + /// Any additional headers you wish to add to this request. + var additionalHeaders: [String: String] { get set } + + /// The `CachePolicy` to use for this request. + var cachePolicy: CachePolicy { get set } + + /// [optional] A context that is being passed through the request chain. + var context: (any RequestContext)? { get set } + + func toURLRequest() throws -> URLRequest +} + +// MARK: - Helper Functions + +extension GraphQLRequest { + + /// Creates a default `URLRequest` for the receiver. + /// + /// This can be called within the implementation of `toURLRequest()` and the returned request + /// can then be modified as necessary before being returned. + /// + /// This function creates a `URLRequest` with the following behaviors: + /// - `url` set to the receiver's `graphQLEndpoint` + /// - `httpMethod` set to POST + /// - All header's from `additionalHeaders` added to `allHTTPHeaderFields` + /// - If the `context` conforms to `RequestContextTimeoutConfigurable`, the `timeoutInterval` is + /// set to the context's `requestTimeout`. + /// + /// - Returns: A `URLRequest` configured as described above. + public func createDefaultRequest() -> URLRequest { + var request = URLRequest(url: self.graphQLEndpoint) + + request.httpMethod = GraphQLHTTPMethod.POST.rawValue + + for (fieldName, value) in additionalHeaders { + request.addValue(value, forHTTPHeaderField: fieldName) + } + + if let configContext = context as? any RequestContextTimeoutConfigurable { + request.timeoutInterval = configContext.requestTimeout + } + + return request + } + + public mutating func addHeader(name: String, value: String) { + self.additionalHeaders[name] = value + } + + public mutating func addHeaders(_ headers: [String: String]) { + self.additionalHeaders.merge(headers) { (_, new) in new } + } + + /// A helper method that dds the Apollo client headers to the given request + /// These header values are used for telemetry to track the source of client requests. + /// + /// This should be called during setup of any implementation of `GraphQLRequest` to provide these + /// header values. + /// + /// - Parameters: + /// - clientName: The client name. Defaults to the application's bundle identifier + "-apollo-ios". + /// - clientVersion: The client version. Defaults to the bundle's short version or build number. + public mutating func addApolloClientHeaders( + clientName: String? = Self.defaultClientName, + clientVersion: String? = Self.defaultClientVersion + ) { + additionalHeaders[Self.headerFieldNameApolloClientName] = clientName + additionalHeaders[Self.headerFieldNameApolloClientVersion] = clientVersion + } + + /// The field name for the Apollo Client Name header + static var headerFieldNameApolloClientName: String { + return "apollographql-client-name" + } + + /// The field name for the Apollo Client Version header + static var headerFieldNameApolloClientVersion: String { + return "apollographql-client-version" + } + + /// The default client name to use when setting up the `clientName` property + public static var defaultClientName: String { + guard let identifier = Bundle.main.bundleIdentifier else { + return "apollo-ios-client" + } + + return "\(identifier)-apollo-ios" + } + + /// The default client version to use when setting up the `clientVersion` property. + public static var defaultClientVersion: String { + var version = String() + if let shortVersion = Bundle.main.shortVersion { + version.append(shortVersion) + } + + if let buildNumber = Bundle.main.buildNumber { + if version.isEmpty { + version.append(buildNumber) + } else { + version.append("-\(buildNumber)") + } + } + + if version.isEmpty { + version = "(unknown)" + } + + return version + } + +} diff --git a/apollo-ios/Sources/Apollo/GraphQLResponse.swift b/apollo-ios/Sources/Apollo/GraphQLResponse.swift index ddc36e8d0..1bdbb650f 100644 --- a/apollo-ios/Sources/Apollo/GraphQLResponse.swift +++ b/apollo-ios/Sources/Apollo/GraphQLResponse.swift @@ -2,6 +2,7 @@ import ApolloAPI #endif +#warning("TODO: kill") /// Represents a complete GraphQL response received from a server. public final class GraphQLResponse { private let base: AnyGraphQLResponse diff --git a/apollo-ios/Sources/Apollo/GraphQLResult.swift b/apollo-ios/Sources/Apollo/GraphQLResult.swift index c07565b98..f91754d7a 100644 --- a/apollo-ios/Sources/Apollo/GraphQLResult.swift +++ b/apollo-ios/Sources/Apollo/GraphQLResult.swift @@ -2,7 +2,8 @@ @_spi(Internal) import ApolloAPI #endif -#warning("TODO: maybe change to generic over Operation to give more access to Operation type info?") +#warning("TODO: maybe change to generic over Operation to give more access to Operation type info? 3.0?") +#warning("TODO: change to GraphQLResponse when we delete the existing response object?") /// Represents the result of a GraphQL operation. public struct GraphQLResult: Sendable { diff --git a/apollo-ios/Sources/Apollo/HTTPRequest.swift b/apollo-ios/Sources/Apollo/HTTPRequest.swift deleted file mode 100644 index 27e5afd78..000000000 --- a/apollo-ios/Sources/Apollo/HTTPRequest.swift +++ /dev/null @@ -1,139 +0,0 @@ -import Foundation -#if !COCOAPODS -import ApolloAPI -#endif - -/// Encapsulation of all information about a request before it hits the network -#warning("TODO: temp @unchecked Sendable to move forward; is not yet correct while its still open. Need to compose JSONRequest instead of inherit.") -open class HTTPRequest: @unchecked Sendable, Hashable { - - /// The endpoint to make a GraphQL request to - open var graphQLEndpoint: URL - - /// The GraphQL Operation to execute - open var operation: Operation - - /// Any additional headers you wish to add by default to this request - open var additionalHeaders: [String: String] - - /// The `CachePolicy` to use for this request. - open var cachePolicy: CachePolicy - - /// [optional] A unique identifier for this request, to help with deduping cache hits for watchers. - public let contextIdentifier: UUID? - - /// [optional] A context that is being passed through the request chain. - public let context: (any RequestContext)? -#warning("TODO: look into replacing this with Task local values?") - - /// Designated Initializer - /// - /// - Parameters: - /// - graphQLEndpoint: The endpoint to make a GraphQL request to - /// - operation: The GraphQL Operation to execute - /// - contextIdentifier: [optional] A unique identifier for this request, to help with deduping cache hits for watchers. Defaults to `nil`. - /// - contentType: The `Content-Type` header's value. Should usually be set for you by a subclass. - /// - clientName: The name of the client to send with the `"apollographql-client-name"` header - /// - clientVersion: The version of the client to send with the `"apollographql-client-version"` header - /// - additionalHeaders: Any additional headers you wish to add by default to this request. - /// - cachePolicy: The `CachePolicy` to use for this request. Defaults to the `.default` policy - /// - context: [optional] A context that is being passed through the request chain. Defaults to `nil`. - public init(graphQLEndpoint: URL, - operation: Operation, - contextIdentifier: UUID? = nil, - contentType: String, - clientName: String, - clientVersion: String, - additionalHeaders: [String: String], - cachePolicy: CachePolicy = .default, - context: (any RequestContext)? = nil) { - self.graphQLEndpoint = graphQLEndpoint - self.operation = operation - self.contextIdentifier = contextIdentifier - self.additionalHeaders = additionalHeaders - self.cachePolicy = cachePolicy - self.context = context - - self.addHeader(name: "Content-Type", value: contentType) - // Note: in addition to this being a generally useful header to send, Apollo - // Server's CSRF prevention feature (introduced in AS3.7 and intended to be - // the default in AS4) includes this in the set of headers that indicate - // that a GET request couldn't have been a non-preflighted simple request - // and thus is safe to execute. If this project is changed to not always - // send this header, its GET requests may be blocked by Apollo Server with - // CSRF prevention enabled. See - // https://www.apollographql.com/docs/apollo-server/security/cors/#preventing-cross-site-request-forgery-csrf - // for details. - self.addHeader(name: "X-APOLLO-OPERATION-NAME", value: Operation.operationName) - self.addHeader(name: "X-APOLLO-OPERATION-TYPE", value: String(describing: Operation.operationType)) - if let operationID = Operation.operationIdentifier { - self.addHeader(name: "X-APOLLO-OPERATION-ID", value: operationID) - } - - self.addHeader(name: "apollographql-client-version", value: clientVersion) - self.addHeader(name: "apollographql-client-name", value: clientName) - } - - open func addHeader(name: String, value: String) { - self.additionalHeaders[name] = value - } - - open func updateContentType(to contentType: String) { - self.addHeader(name: "Content-Type", value: contentType) - } - - /// Converts this object to a fully fleshed-out `URLRequest` - /// - /// - Throws: Any error in creating the request - /// - Returns: The URL request, ready to send to your server. - open func toURLRequest() throws -> URLRequest { - var request: URLRequest - - if let configContext = context as? any RequestContextTimeoutConfigurable { - request = URLRequest(url: self.graphQLEndpoint, timeoutInterval: configContext.requestTimeout) - } else { - request = URLRequest(url: self.graphQLEndpoint) - } - - for (fieldName, value) in additionalHeaders { - request.addValue(value, forHTTPHeaderField: fieldName) - } - - return request - } - - // MARK: - Hashable Conformance - - public func hash(into hasher: inout Hasher) { - hasher.combine(graphQLEndpoint) - hasher.combine(operation) - hasher.combine(additionalHeaders) - hasher.combine(cachePolicy) - hasher.combine(contextIdentifier) - } - - public static func == (lhs: HTTPRequest, rhs: HTTPRequest) -> Bool { - lhs.graphQLEndpoint == rhs.graphQLEndpoint && - lhs.operation == rhs.operation && - lhs.additionalHeaders == rhs.additionalHeaders && - lhs.cachePolicy == rhs.cachePolicy && - lhs.contextIdentifier == rhs.contextIdentifier - } -} - -extension HTTPRequest: CustomDebugStringConvertible { - public var debugDescription: String { - var debugStrings = [String]() - debugStrings.append("HTTPRequest details:") - debugStrings.append("Endpoint: \(self.graphQLEndpoint)") - debugStrings.append("Additional Headers: [") - for (key, value) in self.additionalHeaders { - debugStrings.append("\t\(key): \(value),") - } - debugStrings.append("]") - debugStrings.append("Cache Policy: \(self.cachePolicy)") - debugStrings.append("Operation: \(self.operation)") - debugStrings.append("Context identifier: \(String(describing: self.contextIdentifier))") - return debugStrings.joined(separator: "\n\t") - } -} diff --git a/apollo-ios/Sources/Apollo/HTTPResponse.swift b/apollo-ios/Sources/Apollo/HTTPResponse.swift deleted file mode 100644 index 44bd7adda..000000000 --- a/apollo-ios/Sources/Apollo/HTTPResponse.swift +++ /dev/null @@ -1,64 +0,0 @@ -import Foundation -#if !COCOAPODS -import ApolloAPI -#endif - -#warning(""" -TODO: Can we kill this? Data is not all data for response in multi-part, making this confusing. -Alternatively, add more properties to help understand which portion of a multi-part response is being returned. -""") -/// Data about a response received by an HTTP request. -public struct HTTPResponse: Sendable { - - /// The `HTTPURLResponse` received from the URL loading system - public var httpResponse: HTTPURLResponse - - /// The raw data received from the URL loading system - public var rawData: Data - - /// [optional] The data as parsed into a `GraphQLResult`, which can eventually be returned to the UI. Will be nil - /// if not yet parsed. - public var parsedResponse: GraphQLResult? - - /// A set of cache records from the response - public var cacheRecords: RecordSet? - - /// Designated initializer - /// - /// - Parameters: - /// - response: The `HTTPURLResponse` received from the server. - /// - rawData: The raw, unparsed data received from the server. - /// - parsedResponse: [optional] The response parsed into the `ParsedValue` type. Will be nil if not yet parsed, - /// or if parsing failed. - public init( - response: HTTPURLResponse, - rawData: Data, - parsedResponse: GraphQLResult? - ) { - self.httpResponse = response - self.rawData = rawData - self.parsedResponse = parsedResponse - } -} - -// MARK: - Equatable Conformance - -extension HTTPResponse: Equatable where Operation.Data: Equatable { - public static func == (lhs: HTTPResponse, rhs: HTTPResponse) -> Bool { - lhs.httpResponse == rhs.httpResponse && - lhs.rawData == rhs.rawData && - lhs.parsedResponse == rhs.parsedResponse && - lhs.cacheRecords == rhs.cacheRecords - } -} - -// MARK: - Hashable Conformance - -extension HTTPResponse: Hashable where Operation.Data: Hashable { - public func hash(into hasher: inout Hasher) { - hasher.combine(httpResponse) - hasher.combine(rawData) - hasher.combine(parsedResponse) - hasher.combine(cacheRecords) - } -} diff --git a/apollo-ios/Sources/Apollo/HTTPURLResponse+Helpers.swift b/apollo-ios/Sources/Apollo/HTTPURLResponse+Helpers.swift index 05f298ad4..dd3c94fba 100644 --- a/apollo-ios/Sources/Apollo/HTTPURLResponse+Helpers.swift +++ b/apollo-ios/Sources/Apollo/HTTPURLResponse+Helpers.swift @@ -19,7 +19,7 @@ extension HTTPURLResponse { let boundary: String? let `protocol`: String? - init(media: String? = nil, boundary: String? = nil, protocol: String? = nil) { + init(media: String?, boundary: String?, protocol: String?) { self.media = media self.boundary = boundary self.protocol = `protocol` @@ -29,7 +29,7 @@ extension HTTPURLResponse { /// Components of the `Content-Type` header specifically related to the `multipart` media type. var multipartHeaderComponents: MultipartHeaderComponents { guard let contentType = allHeaderFields["Content-Type"] as? String else { - return MultipartHeaderComponents() + return MultipartHeaderComponents(media: nil, boundary: nil, protocol: nil) } var media: String? = nil diff --git a/apollo-ios/Sources/Apollo/IncrementalJSONResponseParsingInterceptor.swift b/apollo-ios/Sources/Apollo/IncrementalJSONResponseParsingInterceptor.swift deleted file mode 100644 index 51545c2f4..000000000 --- a/apollo-ios/Sources/Apollo/IncrementalJSONResponseParsingInterceptor.swift +++ /dev/null @@ -1,142 +0,0 @@ -import Foundation -#if !COCOAPODS -import ApolloAPI -#endif - -/// An interceptor which parses JSON response data into a `GraphQLResult` and attaches it to the -/// `HTTPResponse`. -public struct IncrementalJSONResponseParsingInterceptor: ApolloInterceptor { - - public enum ParsingError: Error, LocalizedError { - case noResponseToParse - case couldNotParseToJSON(data: Data) - case mismatchedCurrentResultType - case couldNotParseIncrementalJSON(json: JSONValue) - - public var errorDescription: String? { - switch self { - case .noResponseToParse: - return "The JSON response parsing interceptor was called before a response was received. Double-check the order of your interceptors." - - case .couldNotParseToJSON(let data): - var errorStrings = [String]() - errorStrings.append("Could not parse data to JSON format.") - if let dataString = String(bytes: data, encoding: .utf8) { - errorStrings.append("Data received as a String was:") - errorStrings.append(dataString) - } else { - errorStrings.append("Data of count \(data.count) also could not be parsed into a String.") - } - - return errorStrings.joined(separator: " ") - - case .mismatchedCurrentResultType: - return "Partial result type operation does not match incremental result type operation." - - case let .couldNotParseIncrementalJSON(json): - return "Could not parse incremental values - got \(json)." - } - } - } - - public var id: String = UUID().uuidString - private let resultStorage = ResultStorage() - - #warning("TODO: Unchecked and not safe") - private class ResultStorage: @unchecked Sendable { - var currentResult: Any? - var currentCacheRecords: RecordSet? - } - - public init() { } - - public func interceptAsync( - chain: any RequestChain, - request: HTTPRequest, - response: HTTPResponse?, - completion: @escaping GraphQLResultHandler - ) { - guard var createdResponse = response else { - chain.handleErrorAsync( - ParsingError.noResponseToParse, - request: request, - response: response, - completion: completion - ) - return - } - - Task { - do { - guard - let body = try? JSONSerializationFormat.deserialize(data: createdResponse.rawData) as JSONObject - else { - throw ParsingError.couldNotParseToJSON(data: createdResponse.rawData) - } - - let parsedResult: GraphQLResult - let parsedCacheRecords: RecordSet? - - if let currentResult = resultStorage.currentResult { - guard var currentResult = currentResult as? GraphQLResult else { - throw ParsingError.mismatchedCurrentResultType - } - - guard let incrementalItems = body["incremental"] as? [JSONObject] else { - throw ParsingError.couldNotParseIncrementalJSON(json: body as JSONValue) - } - - var currentCacheRecords = resultStorage.currentCacheRecords ?? RecordSet() - - for item in incrementalItems { - let incrementalResponse = try IncrementalGraphQLResponse( - operation: request.operation, - body: item - ) - let (incrementalResult, incrementalCacheRecords) = try await incrementalResponse.parseIncrementalResult( - withCachePolicy: request.cachePolicy - ) - currentResult = try currentResult.merging(incrementalResult) - - if let incrementalCacheRecords { - currentCacheRecords.merge(records: incrementalCacheRecords) - } - } - - parsedResult = currentResult - parsedCacheRecords = currentCacheRecords - - } else { - let graphQLResponse = GraphQLResponse(operation: request.operation, body: body) - - let (result, cacheRecords) = try await graphQLResponse.parseResult(withCachePolicy: request.cachePolicy) - - parsedResult = result - parsedCacheRecords = cacheRecords - } - - createdResponse.parsedResponse = parsedResult - createdResponse.cacheRecords = parsedCacheRecords - - resultStorage.currentResult = parsedResult - resultStorage.currentCacheRecords = parsedCacheRecords - - chain.proceedAsync( - request: request, - response: createdResponse, - interceptor: self, - completion: completion - ) - - } catch { - chain.handleErrorAsync( - error, - request: request, - response: createdResponse, - completion: completion - ) - } - } - } - -} diff --git a/apollo-ios/Sources/Apollo/InterceptorRequestChain.swift b/apollo-ios/Sources/Apollo/InterceptorRequestChain.swift deleted file mode 100644 index 2386bf429..000000000 --- a/apollo-ios/Sources/Apollo/InterceptorRequestChain.swift +++ /dev/null @@ -1,281 +0,0 @@ -import Foundation -#if !COCOAPODS -import ApolloAPI -#endif - -/// A chain that allows a single network request to be created and executed. -final public class InterceptorRequestChain: Cancellable, RequestChain { - - public enum ChainError: Error, LocalizedError { - case invalidIndex(index: Int) - case noInterceptors - case unknownInterceptor(id: String) - - public var errorDescription: String? { - switch self { - case .noInterceptors: - return "No interceptors were provided to this chain. This is a developer error." - case .invalidIndex(let index): - return "`proceedAsync` was called for index \(index), which is out of bounds of the receiver for this chain. Double-check the order of your interceptors." - case let .unknownInterceptor(id): - return "`proceedAsync` was called by unknown interceptor \(id)." - } - } - } - - private let interceptors: [any ApolloInterceptor] - private let callbackQueue: DispatchQueue - -#warning("TODO: Unsafe, but We should be getting rid of these completely.") - nonisolated(unsafe) private var interceptorIndexes: [String: Int] = [:] - nonisolated(unsafe) private var currentIndex: Int - - public let cancellationState = CancellationState() - public var isCancelled: Bool { cancellationState.isCancelled } - -#warning("TODO: Unsafe.") - /// Something which allows additional error handling to occur when some kind of error has happened. - nonisolated(unsafe) public var additionalErrorHandler: (any ApolloErrorInterceptor)? - - /// Creates a chain with the given interceptor array. - /// - /// - Parameters: - /// - interceptors: The array of interceptors to use. - /// - callbackQueue: The `DispatchQueue` to call back on when an error or result occurs. - /// Defaults to `.main`. - public init( - interceptors: [any ApolloInterceptor], - callbackQueue: DispatchQueue = .main - ) { - self.interceptors = interceptors - self.callbackQueue = callbackQueue - self.currentIndex = 0 - - for (index, interceptor) in interceptors.enumerated() { - self.interceptorIndexes[interceptor.id] = index - } - } - - /// Kicks off the request from the beginning of the interceptor array. - /// - /// - Parameters: - /// - request: The request to send. - /// - completion: The completion closure to call when the request has completed. - public func kickoff( - request: HTTPRequest, - completion: @escaping GraphQLResultHandler - ) { - assert(self.currentIndex == 0, "The interceptor index should be zero when calling this method") - - guard let firstInterceptor = self.interceptors.first else { - handleErrorAsync( - ChainError.noInterceptors, - request: request, - response: nil, - completion: completion - ) - return - } - - firstInterceptor.interceptAsync( - chain: self, - request: request, - response: nil, - completion: completion - ) - } - - /// Proceeds to the next interceptor in the array. - /// - /// - Parameters: - /// - request: The in-progress request object - /// - response: [optional] The in-progress response object, if received yet - /// - completion: The completion closure to call when data has been processed and should be - /// returned to the UI. - public func proceedAsync( - request: HTTPRequest, - response: HTTPResponse?, - completion: @escaping GraphQLResultHandler - ) { - let nextIndex = self.currentIndex + 1 - - proceedAsync( - interceptorIndex: nextIndex, - request: request, - response: response, - completion: completion - ) - } - - /// Proceeds to the next interceptor in the array. - /// - /// - Parameters: - /// - request: The in-progress request object - /// - response: [optional] The in-progress response object, if received yet - /// - interceptor: The interceptor that has completed processing and is ready to pass control - /// on to the next interceptor in the chain. - /// - completion: The completion closure to call when data has been processed and should be - /// returned to the UI. - public func proceedAsync( - request: HTTPRequest, - response: HTTPResponse?, - interceptor: any ApolloInterceptor, - completion: @escaping GraphQLResultHandler - ) { - guard let currentIndex = interceptorIndexes[interceptor.id] else { - self.handleErrorAsync( - ChainError.unknownInterceptor(id: interceptor.id), - request: request, - response: response, - completion: completion - ) - return - } - - let nextIndex = currentIndex + 1 - - proceedAsync( - interceptorIndex: nextIndex, - request: request, - response: response, - completion: completion - ) - } - - private func proceedAsync( - interceptorIndex: Int, - request: HTTPRequest, - response: HTTPResponse?, - completion: @escaping GraphQLResultHandler - ) { - guard !self.isCancelled else { - // Do not proceed, this chain has been cancelled. - return - } - - if self.interceptors.indices.contains(interceptorIndex) { - self.currentIndex = interceptorIndex - let interceptor = self.interceptors[interceptorIndex] - - interceptor.interceptAsync( - chain: self, - request: request, - response: response, - completion: completion - ) - - } else { - if let result = response?.parsedResponse { - // We got to the end of the chain with a parsed response. Yay! Return it. - self.returnValueAsync( - for: request, - value: result, - completion: completion - ) - - } else { - // We got to the end of the chain and no parsed response is there, there needs to be more processing. - self.handleErrorAsync( - ChainError.invalidIndex(index: interceptorIndex), - request: request, - response: response, - completion: completion - ) - } - } - } - - /// Cancels the entire chain of interceptors. - public func cancel() { - guard !self.isCancelled else { - // Do not proceed, this chain has been cancelled. - return - } - - self.cancellationState.cancel() - - // If an interceptor adheres to `Cancellable`, it should have its in-flight work cancelled as well. - for interceptor in self.interceptors { - if let cancellableInterceptor = interceptor as? (any Cancellable) { - cancellableInterceptor.cancel() - } - } - } - - /// Restarts the request starting from the first interceptor. - /// - /// - Parameters: - /// - request: The request to retry - /// - completion: The completion closure to call when the request has completed. - public func retry( - request: HTTPRequest, - completion: @escaping GraphQLResultHandler - ) { - guard !self.isCancelled else { - // Don't retry something that's been cancelled. - return - } - - self.currentIndex = 0 - self.kickoff(request: request, completion: completion) - } - - /// Handles the error by returning it on the appropriate queue, or by applying an additional - /// error interceptor if one has been provided. - /// - /// - Parameters: - /// - error: The error to handle - /// - request: The request, as far as it has been constructed. - /// - response: The response, as far as it has been constructed. - /// - completion: The completion closure to call when work is complete. - public func handleErrorAsync( - _ error: any Error, - request: HTTPRequest, - response: HTTPResponse?, - completion: @escaping @Sendable (Result, any Error>) -> Void - ) { - guard !self.isCancelled else { - return - } - - guard let additionalHandler = self.additionalErrorHandler else { - self.callbackQueue.async { - completion(.failure(error)) - } - return - } - - // Capture callback queue so it doesn't get reaped when `self` is dealloced - let callbackQueue = self.callbackQueue - additionalHandler.handleErrorAsync( - error: error, - chain: self, - request: request, - response: response - ) { result in - callbackQueue.async { - completion(result) - } - } - } - - /// Handles a resulting value by returning it on the appropriate queue. - /// - /// - Parameters: - /// - request: The request, as far as it has been constructed. - /// - value: The value to be returned - /// - completion: The completion closure to call when work is complete. - public func returnValueAsync( - for request: HTTPRequest, - value: GraphQLResult, - completion: @escaping (Result, any Error>) -> Void - ) { - guard !self.isCancelled else { - return - } - - self.callbackQueue.async { - completion(.success(value)) - } - } -} diff --git a/apollo-ios/Sources/Apollo/ApolloErrorInterceptor.swift b/apollo-ios/Sources/Apollo/Interceptors/ApolloErrorInterceptor.swift similarity index 67% rename from apollo-ios/Sources/Apollo/ApolloErrorInterceptor.swift rename to apollo-ios/Sources/Apollo/Interceptors/ApolloErrorInterceptor.swift index dfcdf9ae6..9ba2a6008 100644 --- a/apollo-ios/Sources/Apollo/ApolloErrorInterceptor.swift +++ b/apollo-ios/Sources/Apollo/Interceptors/ApolloErrorInterceptor.swift @@ -3,8 +3,9 @@ import ApolloAPI #endif /// An error interceptor called to allow further examination of error data when an error occurs in the chain. -public protocol ApolloErrorInterceptor { - +#warning("TODO: Kill this, or implement it's usage in Request Chain.") +public protocol ApolloErrorInterceptor: Sendable { + /// Asynchronously handles the receipt of an error at any point in the chain. /// /// - Parameters: @@ -13,10 +14,10 @@ public protocol ApolloErrorInterceptor { /// - request: The request, as far as it was constructed /// - response: [optional] The response, if one was received /// - completion: The completion closure to fire when the operation has completed. Note that if you call `retry` on the chain, you will not want to call the completion block in this method. - func handleErrorAsync( - error: any Error, - chain: any RequestChain, - request: HTTPRequest, - response: HTTPResponse?, - completion: @escaping @Sendable (Result, any Error>) -> Void) + func intercept( + error: any Swift.Error, + request: Request, + result: InterceptorResult? + ) async throws -> GraphQLResult + } diff --git a/apollo-ios/Sources/Apollo/Interceptors/ApolloInterceptor.swift b/apollo-ios/Sources/Apollo/Interceptors/ApolloInterceptor.swift new file mode 100644 index 000000000..484c084fc --- /dev/null +++ b/apollo-ios/Sources/Apollo/Interceptors/ApolloInterceptor.swift @@ -0,0 +1,116 @@ +import Foundation +import Combine +#if !COCOAPODS +import ApolloAPI +#endif + +public struct InterceptorResult: Sendable, Equatable { + + public let response: HTTPURLResponse + + /// This is the data for a single chunk of the response bondy. + /// + /// If this is not a multipart response, this will include the data for the entire response body. + /// + /// If this is a multipart response, the response chunk will only one chunk. + /// The `InterceptorResultStream` will return multiple results – one for each multipart chunk. + public let rawResponseChunk: Data + + public var parsedResult: ParsedResult? + + public struct ParsedResult: Sendable, Equatable { + public let result: GraphQLResult + public let cacheRecords: RecordSet? + } + +} + +#warning("TODO: Wrap RequestChain apis in SPI?") + +/// A protocol to set up a chainable unit of networking work. +#warning("Rename to RequestInterceptor? Or like Apollo Link?") +#warning("Should this take `any GraphQLRequest instead? Let interceptor swap out entire request? Probably can't initialize a new request currently since generic context won't know runtime type.") +public protocol ApolloInterceptor: Sendable { + + typealias NextInterceptorFunction = @Sendable (Request) async throws -> InterceptorResultStream + + /// Called when this interceptor should do its work. + /// + /// - Parameters: + /// - chain: The chain the interceptor is a part of. + /// - request: The request, as far as it has been constructed + /// - response: [optional] The response, if received + /// - completion: The completion block to fire when data needs to be returned to the UI. + func intercept( + request: Request, + next: NextInterceptorFunction + ) async throws -> InterceptorResultStream + +} + +public struct InterceptorResultStream: Sendable, ~Copyable { + + private let stream: AsyncThrowingStream, any Error> + + init(stream: AsyncThrowingStream, any Error>) { + self.stream = stream + } + + public consuming func map( + _ transform: @escaping @Sendable (InterceptorResult) async throws -> InterceptorResult + ) async throws -> InterceptorResultStream { + let stream = self.stream + + let newStream = AsyncThrowingStream { continuation in + let task = Task { + do { + for try await result in stream { + try Task.checkCancellation() + + try await continuation.yield(transform(result)) + } + continuation.finish() + + } catch { + continuation.finish(throwing: error) + } + } + + continuation.onTermination = { _ in task.cancel() } + } + return Self.init(stream: newStream) + } + + public consuming func compactMap( + _ transform: @escaping @Sendable (InterceptorResult) async throws -> InterceptorResult? + ) async throws -> InterceptorResultStream { + let stream = self.stream + + let newStream = AsyncThrowingStream { continuation in + let task = Task { + do { + for try await result in stream { + try Task.checkCancellation() + + guard let newResult = try await transform(result) else { + continue + } + + continuation.yield(newResult) + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + + continuation.onTermination = { _ in task.cancel() } + } + return Self.init(stream: newStream) + } + + public consuming func getResults() -> AsyncThrowingStream, any Error> { + return stream + } + +} diff --git a/apollo-ios/Sources/Apollo/Interceptors/AutomaticPersistedQueryInterceptor.swift b/apollo-ios/Sources/Apollo/Interceptors/AutomaticPersistedQueryInterceptor.swift new file mode 100644 index 000000000..0fcafce22 --- /dev/null +++ b/apollo-ios/Sources/Apollo/Interceptors/AutomaticPersistedQueryInterceptor.swift @@ -0,0 +1,89 @@ +import Foundation +#if !COCOAPODS +import ApolloAPI +#endif + +public struct AutomaticPersistedQueryInterceptor: ApolloInterceptor { + + public enum APQError: LocalizedError, Equatable { + case noParsedResponse + case persistedQueryNotFoundForPersistedOnlyQuery(operationName: String) + case persistedQueryRetryFailed(operationName: String) + + public var errorDescription: String? { + switch self { + case .noParsedResponse: + return "The Automatic Persisted Query Interceptor was called before a response was received. Double-check the order of your interceptors." + case .persistedQueryRetryFailed(let operationName): + return "Persisted query retry failed for operation \"\(operationName)\"." + + case .persistedQueryNotFoundForPersistedOnlyQuery(let operationName): + return "The Persisted Query for operation \"\(operationName)\" was not found. The operation is a `.persistedOnly` operation and cannot be automatically persisted if it is not recognized by the server." + + } + } + } + + /// Designated initializer + public init() {} + + actor IsInitialResult { + var value = true + + func get() -> Bool { + defer { value = false } + return value + } + } + + public func intercept( + request: Request, + next: NextInterceptorFunction + ) async throws -> InterceptorResultStream { + guard let jsonRequest = request as? JSONRequest, + jsonRequest.apqConfig.autoPersistQueries else { + // Not a request that handles APQs, continue along + return try await next(request) + } + + let isInitialResult = IsInitialResult() + + return try await next(request).map { result in + + guard await isInitialResult.get() else { + return result + } + + guard let parsedResult = result.parsedResult else { + throw APQError.noParsedResponse + } + + guard let errors = parsedResult.result.errors else { + // No errors were returned so no retry is necessary, continue along. + return result + } + + let errorMessages = errors.compactMap { $0.message } + guard errorMessages.contains("PersistedQueryNotFound") else { + // The errors were not APQ errors, continue along. + return result + } + + guard !jsonRequest.isPersistedQueryRetry else { + // We already retried this and it didn't work. + throw APQError.persistedQueryRetryFailed(operationName: Request.Operation.operationName) + } + + if Request.Operation.operationDocument.definition == nil { + throw APQError.persistedQueryNotFoundForPersistedOnlyQuery( + operationName: Request.Operation.operationName + ) + } + + var jsonRequest = jsonRequest + // We need to retry this query with the full body. + jsonRequest.isPersistedQueryRetry = true + throw RequestChainRetry(request: jsonRequest) + } + } +} diff --git a/apollo-ios/Sources/Apollo/Interceptors/CacheInterceptor.swift b/apollo-ios/Sources/Apollo/Interceptors/CacheInterceptor.swift new file mode 100644 index 000000000..e251e2afa --- /dev/null +++ b/apollo-ios/Sources/Apollo/Interceptors/CacheInterceptor.swift @@ -0,0 +1,38 @@ +#if !COCOAPODS +import ApolloAPI +#endif + +public protocol CacheInterceptor: Sendable { + + func readCacheData( + for query: Query + ) async throws -> GraphQLResult + + func writeCacheData( + cacheRecords: RecordSet, + for operation: Operation, + with result: GraphQLResult + ) async throws + +} + +public struct DefaultCacheInterceptor: CacheInterceptor { + + let store: ApolloStore + + public func readCacheData( + for query: Query + ) async throws -> GraphQLResult { + return try await store.load(query) + } + + public func writeCacheData( + cacheRecords: RecordSet, + for operation: Operation, + with result: GraphQLResult + ) async throws { + #warning("TODO: need to pass through context identifier. Can we use task local values?") + try await store.publish(records: cacheRecords) + } + +} diff --git a/apollo-ios/Sources/Apollo/Interceptors/DefaultInterceptorProvider.swift b/apollo-ios/Sources/Apollo/Interceptors/DefaultInterceptorProvider.swift new file mode 100644 index 000000000..32dce495b --- /dev/null +++ b/apollo-ios/Sources/Apollo/Interceptors/DefaultInterceptorProvider.swift @@ -0,0 +1,63 @@ +import Foundation +#if !COCOAPODS +import ApolloAPI +#endif + +/// The default interceptor provider for typescript-generated code +public final class DefaultInterceptorProvider: InterceptorProvider { + + private let session: any ApolloURLSession + private let store: ApolloStore + private let shouldInvalidateClientOnDeinit: Bool + + /// Designated initializer + /// + /// - Parameters: + /// - session: The `ApolloURLSession` to use. Defaults to a URLSession with a default configuration. + /// - shouldInvalidateSessionOnDeinit: If the passed-in session should be invalidated when this interceptor provider is deinitialized. If you are re-creating the `ApolloURLSession` every time you create a new provider, you should do this to prevent memory leaks. Defaults to true, since by default we provide a new `URLSession` to each new instance. + /// - store: The `ApolloStore` to use when reading from or writing to the cache. Make sure you pass the same store to the `ApolloClient` instance you're planning to use. + public init( + session: some ApolloURLSession = URLSession(configuration: .default), + store: ApolloStore, + shouldInvalidateSessionOnDeinit: Bool = true + ) { + self.session = session + self.shouldInvalidateClientOnDeinit = shouldInvalidateSessionOnDeinit + self.store = store + } + + deinit { + if self.shouldInvalidateClientOnDeinit { + self.session.invalidateAndCancel() + } + } + + public func urlSession( + for operation: Operation + ) -> any ApolloURLSession { + session + } + + public func interceptors( + for operation: Operation + ) -> [any ApolloInterceptor] { + return [ + MaxRetryInterceptor(), + AutomaticPersistedQueryInterceptor(), + JSONResponseParsingInterceptor(), + ResponseCodeInterceptor() + ] + } + + public func cacheInterceptor( + for operation: Operation + ) -> any CacheInterceptor { + DefaultCacheInterceptor(store: self.store) + } + + public func errorInterceptor( + for operation: Operation + ) -> (any ApolloErrorInterceptor)? { + return nil + } +} diff --git a/apollo-ios/Sources/Apollo/InterceptorProvider.swift b/apollo-ios/Sources/Apollo/Interceptors/InterceptorProvider.swift similarity index 59% rename from apollo-ios/Sources/Apollo/InterceptorProvider.swift rename to apollo-ios/Sources/Apollo/Interceptors/InterceptorProvider.swift index f4aaa0cb0..f6924630b 100644 --- a/apollo-ios/Sources/Apollo/InterceptorProvider.swift +++ b/apollo-ios/Sources/Apollo/Interceptors/InterceptorProvider.swift @@ -5,24 +5,30 @@ import ApolloAPI // MARK: - Basic protocol /// A protocol to allow easy creation of an array of interceptors for a given operation. -public protocol InterceptorProvider { +public protocol InterceptorProvider: Sendable { + func urlSession(for operation: Operation) -> any ApolloURLSession + /// Creates a new array of interceptors when called /// /// - Parameter operation: The operation to provide interceptors for func interceptors(for operation: Operation) -> [any ApolloInterceptor] - + + func cacheInterceptor(for operation: Operation) -> any CacheInterceptor + /// Provides an additional error interceptor for any additional handling of errors /// before returning to the UI, such as logging. /// - Parameter operation: The operation to provide an additional error interceptor for - func additionalErrorInterceptor(for operation: Operation) -> (any ApolloErrorInterceptor)? + func errorInterceptor(for operation: Operation) -> (any ApolloErrorInterceptor)? } /// MARK: - Default Implementation public extension InterceptorProvider { - func additionalErrorInterceptor(for operation: Operation) -> (any ApolloErrorInterceptor)? { + func errorInterceptor( + for operation: Operation + ) -> (any ApolloErrorInterceptor)? { return nil } } diff --git a/apollo-ios/Sources/Apollo/Interceptors/JSONResponseParsingInterceptor.swift b/apollo-ios/Sources/Apollo/Interceptors/JSONResponseParsingInterceptor.swift new file mode 100644 index 000000000..26eccbcca --- /dev/null +++ b/apollo-ios/Sources/Apollo/Interceptors/JSONResponseParsingInterceptor.swift @@ -0,0 +1,67 @@ +import Foundation +import Combine +#if !COCOAPODS +import ApolloAPI +#endif + +/// An interceptor which parses JSON response data into a `GraphQLResult` and attaches it to the `HTTPResponse`. +public struct JSONResponseParsingInterceptor: ApolloInterceptor { + + public init() { } + + actor CurrentResult { + var value: JSONResponseParser.ParsedResult? = nil + + func set(_ value: JSONResponseParser.ParsedResult) { + self.value = value + } + } + + public func intercept( + request: Request, + next: NextInterceptorFunction + ) async throws -> InterceptorResultStream { + + let currentResult = CurrentResult() + + if let request = request as? UploadRequest { + + } + + return try await next(request).compactMap { result -> InterceptorResult? in + let parser = JSONResponseParser( + response: result.response, + operationVariables: request.operation.__variables, + includeCacheRecords: request.cachePolicy.shouldParsingIncludeCacheRecords + ) + + guard let parsedResult = try await parser.parse( + dataChunk: result.rawResponseChunk, + mergingIncrementalItemsInto: await currentResult.value + ) else { + return nil + } + + await currentResult.set(parsedResult) + return InterceptorResult( + response: result.response, + rawResponseChunk: result.rawResponseChunk, + parsedResult: InterceptorResult.ParsedResult( + result: parsedResult.0, + cacheRecords: parsedResult.1 + )) + } + } +} + +fileprivate extension CachePolicy { + var shouldParsingIncludeCacheRecords: Bool { + switch self { + case .fetchIgnoringCacheCompletely: + return false + + default: + return true + } + } +} diff --git a/apollo-ios/Sources/Apollo/Interceptors/MaxRetryInterceptor.swift b/apollo-ios/Sources/Apollo/Interceptors/MaxRetryInterceptor.swift new file mode 100644 index 000000000..b2d145407 --- /dev/null +++ b/apollo-ios/Sources/Apollo/Interceptors/MaxRetryInterceptor.swift @@ -0,0 +1,43 @@ +import Foundation +#if !COCOAPODS +import ApolloAPI +#endif + +#warning("TODO: remove unchecked when making interceptor functions async.") +/// An interceptor to enforce a maximum number of retries of any `HTTPRequest` +public actor MaxRetryInterceptor: ApolloInterceptor, Sendable { + + private let maxRetries: Int + private var hitCount = 0 + + public struct MaxRetriesError: Error, LocalizedError { + public let count: Int + public let operationName: String + + public var errorDescription: String? { + return "The maximum number of retries (\(count)) was hit without success for operation \"\(operationName)\"." + } + } + + /// Designated initializer. + /// + /// - Parameter maxRetriesAllowed: How many times a query can be retried, in addition to the initial attempt before + public init(maxRetriesAllowed: Int = 3) { + self.maxRetries = maxRetriesAllowed + } + + public func intercept( + request: Request, + next: NextInterceptorFunction + ) async throws -> InterceptorResultStream { + guard self.hitCount <= self.maxRetries else { + throw MaxRetriesError( + count: self.maxRetries, + operationName: Request.Operation.operationName + ) + } + + self.hitCount += 1 + return try await next(request) + } +} diff --git a/apollo-ios/Sources/Apollo/Interceptors/ResponseCodeInterceptor.swift b/apollo-ios/Sources/Apollo/Interceptors/ResponseCodeInterceptor.swift new file mode 100644 index 000000000..e27415995 --- /dev/null +++ b/apollo-ios/Sources/Apollo/Interceptors/ResponseCodeInterceptor.swift @@ -0,0 +1,49 @@ +import Foundation +#if !COCOAPODS +import ApolloAPI +#endif + +/// An interceptor to check the response code returned with a request. +public struct ResponseCodeInterceptor: ApolloInterceptor { + + public var id: String = UUID().uuidString + + public struct ResponseCodeError: Error, LocalizedError { + public let response: HTTPURLResponse + public let responseChunk: Data + + public var errorDescription: String? { + return "Received a \(response.statusCode) error." + } + + public var graphQLError: GraphQLError? { + if let jsonValue = try? (JSONSerialization.jsonObject( + with: responseChunk, + options: .allowFragments) as! JSONValue), + let jsonObject = try? JSONObject(_jsonValue: jsonValue) + { + return GraphQLError(jsonObject) + } + return nil + } + } + + /// Designated initializer + public init() {} + + public func intercept( + request: Request, + next: NextInterceptorFunction + ) async throws -> InterceptorResultStream { + return try await next(request).map { result in + + guard result.response.isSuccessful == true else { + throw ResponseCodeError( + response: result.response, + responseChunk: result.rawResponseChunk + ) + } + return result + } + } +} diff --git a/apollo-ios/Sources/Apollo/JSONRequest.swift b/apollo-ios/Sources/Apollo/JSONRequest.swift index 1119112af..c5498d173 100644 --- a/apollo-ios/Sources/Apollo/JSONRequest.swift +++ b/apollo-ios/Sources/Apollo/JSONRequest.swift @@ -4,30 +4,42 @@ import Foundation #endif /// A request which sends JSON related to a GraphQL operation. -open class JSONRequest: HTTPRequest { +public struct JSONRequest: GraphQLRequest, AutoPersistedQueryCompatibleRequest, Hashable { - public let requestBodyCreator: any RequestBodyCreator - public let autoPersistQueries: Bool - public let useGETForQueries: Bool - public let useGETForPersistedQueryRetry: Bool - public let serializationFormat = JSONSerializationFormat.self + /// The endpoint to make a GraphQL request to + public var graphQLEndpoint: URL - private let sendEnhancedClientAwareness: Bool + /// The GraphQL Operation to execute + public var operation: Operation - public var isPersistedQueryRetry = false { - didSet { - _body = nil - } - } + /// Any additional headers you wish to add to this request + public var additionalHeaders: [String: String] = [:] - private var _body: JSONEncodableDictionary? - public var body: JSONEncodableDictionary { - if _body == nil { - _body = createBody() - } - return _body! - } + /// The `CachePolicy` to use for this request. + public var cachePolicy: CachePolicy + + /// [optional] A unique identifier for this request, to help with deduping cache hits for watchers. + public let contextIdentifier: UUID? + + /// [optional] A context that is being passed through the request chain. + public var context: (any RequestContext)? + public let requestBodyCreator: any JSONRequestBodyCreator + + public var apqConfig: AutoPersistedQueryConfiguration + + public var isPersistedQueryRetry: Bool = false + + /// Set to `true` if you want to use `GET` instead of `POST` for queries. + /// + /// This can improve performance if your GraphQL server uses a CDN (Content Delivery Network) + /// to cache the results of queries that rarely change. + /// + /// Mutation operations always use POST, even when this is `false` + public let useGETForQueries: Bool + + private let sendEnhancedClientAwareness: Bool + /// Designated initializer /// /// - Parameters: @@ -36,58 +48,77 @@ open class JSONRequest: HTTPRequest { /// - contextIdentifier: [optional] A unique identifier for this request, to help with deduping cache hits for watchers. Defaults to `nil`. /// - clientName: The name of the client to send with the `"apollographql-client-name"` header /// - clientVersion: The version of the client to send with the `"apollographql-client-version"` header - /// - additionalHeaders: Any additional headers you wish to add by default to this request /// - cachePolicy: The `CachePolicy` to use for this request. /// - context: [optional] A context that is being passed through the request chain. Defaults to `nil`. - /// - autoPersistQueries: `true` if Auto-Persisted Queries should be used. Defaults to `false`. + /// - apqConfig: A configuration struct used by a `GraphQLRequest` to configure the usage of + /// [Automatic Persisted Queries (APQs).](https://www.apollographql.com/docs/apollo-server/performance/apq) By default, APQs + /// are disabled. /// - useGETForQueries: `true` if Queries should use `GET` instead of `POST` for HTTP requests. Defaults to `false`. - /// - useGETForPersistedQueryRetry: `true` if when an Auto-Persisted query is retried, it should use `GET` instead of `POST` to send the query. Defaults to `false`. - /// - requestBodyCreator: An object conforming to the `RequestBodyCreator` protocol to assist with creating the request body. Defaults to the provided `ApolloRequestBodyCreator` implementation. + /// - requestBodyCreator: An object conforming to the `JSONRequestBodyCreator` protocol to assist with creating the request body. Defaults to the provided `DefaultRequestBodyCreator` implementation. public init( operation: Operation, graphQLEndpoint: URL, contextIdentifier: UUID? = nil, - clientName: String, - clientVersion: String, - additionalHeaders: [String: String] = [:], + clientName: String? = Self.defaultClientName, + clientVersion: String? = Self.defaultClientVersion, cachePolicy: CachePolicy = .default, context: (any RequestContext)? = nil, - autoPersistQueries: Bool = false, + apqConfig: AutoPersistedQueryConfiguration = .init(), useGETForQueries: Bool = false, - useGETForPersistedQueryRetry: Bool = false, - requestBodyCreator: any RequestBodyCreator = ApolloRequestBodyCreator(), + requestBodyCreator: any JSONRequestBodyCreator = DefaultRequestBodyCreator(), sendEnhancedClientAwareness: Bool = true ) { - self.autoPersistQueries = autoPersistQueries - self.useGETForQueries = useGETForQueries - self.useGETForPersistedQueryRetry = useGETForPersistedQueryRetry + self.operation = operation + self.graphQLEndpoint = graphQLEndpoint + self.contextIdentifier = contextIdentifier + self.cachePolicy = cachePolicy + self.context = context self.requestBodyCreator = requestBodyCreator + + self.apqConfig = apqConfig + self.useGETForQueries = useGETForQueries self.sendEnhancedClientAwareness = sendEnhancedClientAwareness - super.init( - graphQLEndpoint: graphQLEndpoint, - operation: operation, - contextIdentifier: contextIdentifier, - contentType: "application/json", + self.setupDefaultHeaders( clientName: clientName, - clientVersion: clientVersion, - additionalHeaders: additionalHeaders, - cachePolicy: cachePolicy, - context: context + clientVersion: clientVersion ) } - open override func toURLRequest() throws -> URLRequest { - var request = try super.toURLRequest() + private mutating func setupDefaultHeaders( + clientName: String? = Self.defaultClientName, + clientVersion: String? = Self.defaultClientVersion + ) { + self.addHeader(name: "Content-Type", value: "application/json") + self.addApolloClientHeaders(clientName: clientName, clientVersion: clientVersion) + + if Operation.operationType == .subscription { + self.addHeader( + name: "Accept", + value: "multipart/mixed;\(MultipartResponseSubscriptionParser.protocolSpec),application/graphql-response+json,application/json" + ) + + } else { + self.addHeader( + name: "Accept", + value: "multipart/mixed;\(MultipartResponseDeferParser.protocolSpec),application/graphql-response+json,application/json" + ) + } + } + + public func toURLRequest() throws -> URLRequest { + var request = createDefaultRequest() + let useGetMethod: Bool - let body = self.body - + let body = self.createBody() + switch Operation.operationType { case .query: if isPersistedQueryRetry { - useGetMethod = self.useGETForPersistedQueryRetry + useGetMethod = self.apqConfig.useGETForPersistedQueryRetry } else { - useGetMethod = self.useGETForQueries || (self.autoPersistQueries && self.useGETForPersistedQueryRetry) + useGetMethod = self.useGETForQueries || + (self.apqConfig.autoPersistQueries && self.apqConfig.useGETForPersistedQueryRetry) } default: useGetMethod = false @@ -110,7 +141,7 @@ open class JSONRequest: HTTPRequest { } case .POST: do { - request.httpBody = try serializationFormat.serialize(value: body) + request.httpBody = try JSONSerializationFormat.serialize(value: body) request.httpMethod = GraphQLHTTPMethod.POST.rawValue } catch { throw GraphQLHTTPRequestError.serializedBodyMessageError @@ -130,16 +161,16 @@ open class JSONRequest: HTTPRequest { sendQueryDocument = true autoPersistQueries = true } else { - sendQueryDocument = !self.autoPersistQueries - autoPersistQueries = self.autoPersistQueries + sendQueryDocument = !self.apqConfig.autoPersistQueries + autoPersistQueries = self.apqConfig.autoPersistQueries } case .mutation: if isPersistedQueryRetry { sendQueryDocument = true autoPersistQueries = true } else { - sendQueryDocument = !self.autoPersistQueries - autoPersistQueries = self.autoPersistQueries + sendQueryDocument = !self.apqConfig.autoPersistQueries + autoPersistQueries = self.apqConfig.autoPersistQueries } default: sendQueryDocument = true @@ -147,7 +178,7 @@ open class JSONRequest: HTTPRequest { } var body = self.requestBodyCreator.requestBody( - for: operation, + for: self, sendQueryDocument: sendQueryDocument, autoPersistQuery: autoPersistQueries ) @@ -177,24 +208,48 @@ open class JSONRequest: HTTPRequest { // MARK: - Equtable/Hashable Conformance - public static func == (lhs: JSONRequest, rhs: JSONRequest) -> Bool { - lhs as HTTPRequest == rhs as HTTPRequest && - type(of: lhs.requestBodyCreator) == type(of: rhs.requestBodyCreator) && - lhs.autoPersistQueries == rhs.autoPersistQueries && - lhs.useGETForQueries == rhs.useGETForQueries && - lhs.useGETForPersistedQueryRetry == rhs.useGETForPersistedQueryRetry && + public static func == ( + lhs: JSONRequest, + rhs: JSONRequest + ) -> Bool { + lhs.graphQLEndpoint == rhs.graphQLEndpoint && + lhs.operation == rhs.operation && + lhs.additionalHeaders == rhs.additionalHeaders && + lhs.cachePolicy == rhs.cachePolicy && + lhs.contextIdentifier == rhs.contextIdentifier && + lhs.apqConfig == rhs.apqConfig && lhs.isPersistedQueryRetry == rhs.isPersistedQueryRetry && - AnySendableHashable.equatableCheck(lhs.body._jsonValue, rhs.body._jsonValue) + lhs.useGETForQueries == rhs.useGETForQueries && + type(of: lhs.requestBodyCreator) == type(of: rhs.requestBodyCreator) } - public override func hash(into hasher: inout Hasher) { - super.hash(into: &hasher) - hasher.combine("\(type(of: requestBodyCreator))") - hasher.combine(autoPersistQueries) - hasher.combine(useGETForQueries) - hasher.combine(useGETForPersistedQueryRetry) + public func hash(into hasher: inout Hasher) { + hasher.combine(graphQLEndpoint) + hasher.combine(operation) + hasher.combine(additionalHeaders) + hasher.combine(cachePolicy) + hasher.combine(contextIdentifier) + hasher.combine(apqConfig) hasher.combine(isPersistedQueryRetry) - hasher.combine(body._jsonObject) + hasher.combine(useGETForQueries) + hasher.combine("\(type(of: requestBodyCreator))") } } + +extension JSONRequest: CustomDebugStringConvertible { + public var debugDescription: String { + var debugStrings = [String]() + debugStrings.append("HTTPRequest details:") + debugStrings.append("Endpoint: \(self.graphQLEndpoint)") + debugStrings.append("Additional Headers: [") + for (key, value) in self.additionalHeaders { + debugStrings.append("\t\(key): \(value),") + } + debugStrings.append("]") + debugStrings.append("Cache Policy: \(self.cachePolicy)") + debugStrings.append("Operation: \(self.operation)") + debugStrings.append("Context identifier: \(String(describing: self.contextIdentifier))") + return debugStrings.joined(separator: "\n\t") + } +} diff --git a/apollo-ios/Sources/Apollo/JSONResponseParsingInterceptor.swift b/apollo-ios/Sources/Apollo/JSONResponseParsingInterceptor.swift deleted file mode 100644 index 6a50f33a7..000000000 --- a/apollo-ios/Sources/Apollo/JSONResponseParsingInterceptor.swift +++ /dev/null @@ -1,84 +0,0 @@ -import Foundation -#if !COCOAPODS -import ApolloAPI -#endif - -/// An interceptor which parses JSON response data into a `GraphQLResult` and attaches it to the `HTTPResponse`. -public struct JSONResponseParsingInterceptor: ApolloInterceptor { - - public enum JSONResponseParsingError: Error, LocalizedError { - case noResponseToParse - case couldNotParseToJSON(data: Data) - - public var errorDescription: String? { - switch self { - case .noResponseToParse: - return "The Codable Parsing Interceptor was called before a response was received to be parsed. Double-check the order of your interceptors." - case .couldNotParseToJSON(let data): - var errorStrings = [String]() - errorStrings.append("Could not parse data to JSON format.") - if let dataString = String(bytes: data, encoding: .utf8) { - errorStrings.append("Data received as a String was:") - errorStrings.append(dataString) - } else { - errorStrings.append("Data of count \(data.count) also could not be parsed into a String.") - } - - return errorStrings.joined(separator: " ") - } - } - } - - public var id: String = UUID().uuidString - - public init() { } - - public func interceptAsync( - chain: any RequestChain, - request: HTTPRequest, - response: HTTPResponse?, - completion: @escaping GraphQLResultHandler - ) { - guard var createdResponse = response else { - chain.handleErrorAsync( - JSONResponseParsingError.noResponseToParse, - request: request, - response: response, - completion: completion - ) - return - } - - Task { - do { - guard - let body = try? JSONSerializationFormat.deserialize(data: createdResponse.rawData) as JSONObject - else { - throw JSONResponseParsingError.couldNotParseToJSON(data: createdResponse.rawData) - } - - let graphQLResponse = GraphQLResponse(operation: request.operation, body: body) - - let (result, cacheRecords) = try await graphQLResponse.parseResult(withCachePolicy: request.cachePolicy) - createdResponse.parsedResponse = result - createdResponse.cacheRecords = cacheRecords - - chain.proceedAsync( - request: request, - response: createdResponse, - interceptor: self, - completion: completion - ) - - } catch { - chain.handleErrorAsync( - error, - request: request, - response: createdResponse, - completion: completion - ) - } - } - } - -} diff --git a/apollo-ios/Sources/Apollo/MaxRetryInterceptor.swift b/apollo-ios/Sources/Apollo/MaxRetryInterceptor.swift deleted file mode 100644 index 880d36962..000000000 --- a/apollo-ios/Sources/Apollo/MaxRetryInterceptor.swift +++ /dev/null @@ -1,63 +0,0 @@ -import Foundation -#if !COCOAPODS -import ApolloAPI -#endif - -#warning("TODO: remove unchecked when making interceptor functions async.") -/// An interceptor to enforce a maximum number of retries of any `HTTPRequest` -public class MaxRetryInterceptor: ApolloInterceptor, @unchecked Sendable { - - private let maxRetries: Int - @Atomic private var hitCount = 0 - - public var id: String = UUID().uuidString - - public enum RetryError: Error, LocalizedError { - case hitMaxRetryCount(count: Int, operationName: String) - - public var errorDescription: String? { - switch self { - case .hitMaxRetryCount(let count, let operationName): - return "The maximum number of retries (\(count)) was hit without success for operation \"\(operationName)\"." - } - } - } - - /// Designated initializer. - /// - /// - Parameter maxRetriesAllowed: How many times a query can be retried, in addition to the initial attempt before - public init(maxRetriesAllowed: Int = 3) { - self.maxRetries = maxRetriesAllowed - } - - public func interceptAsync( - chain: any RequestChain, - request: HTTPRequest, - response: HTTPResponse?, - completion: @escaping GraphQLResultHandler - ) { - guard self.hitCount <= self.maxRetries else { - let error = RetryError.hitMaxRetryCount( - count: self.maxRetries, - operationName: Operation.operationName - ) - - chain.handleErrorAsync( - error, - request: request, - response: response, - completion: completion - ) - - return - } - - self.$hitCount.mutate { $0 += 1 } - chain.proceedAsync( - request: request, - response: response, - interceptor: self, - completion: completion - ) - } -} diff --git a/apollo-ios/Sources/Apollo/MultipartFormData.swift b/apollo-ios/Sources/Apollo/MultipartFormData.swift index f790ae205..a078f6cbe 100644 --- a/apollo-ios/Sources/Apollo/MultipartFormData.swift +++ b/apollo-ios/Sources/Apollo/MultipartFormData.swift @@ -14,9 +14,6 @@ public final class MultipartFormData { } } - /// A Carriage Return Line Feed character, which will be seen as a newline on both unix and Windows servers. - static let CRLF = "\r\n" - public let boundary: String private var bodyParts: [BodyPart] @@ -29,20 +26,17 @@ public final class MultipartFormData { self.bodyParts = [] } - /// Convenience initializer which uses a pre-defined boundary - public convenience init() { - self.init(boundary: "apollo-ios.boundary.\(UUID().uuidString)") - } - /// Appends the passed-in string as a part of the body. /// /// - Parameters: /// - string: The string to append /// - name: The name of the part to pass along to the server public func appendPart(string: String, name: String) throws { - self.appendPart(data: try self.encode(string: string), - name: name, - contentType: nil) + self.appendPart( + data: try self.encode(string: string), + name: name, + contentType: nil + ) } /// Appends the passed-in data as a part of the body. @@ -104,10 +98,11 @@ public final class MultipartFormData { private func encode(bodyPart: BodyPart) throws -> Data { var encoded = Data() - encoded.append(try self.encode(string: "--\(self.boundary)\(MultipartFormData.CRLF)")) + encoded.append(try self.encode(string: "--\(self.boundary)")) + encoded.append(MultipartResponseParsing.CRLF) encoded.append(try self.encode(string: bodyPart.headers())) encoded.append(self.encode(inputStream: bodyPart.inputStream, length: bodyPart.contentLength)) - encoded.append(try self.encode(string: "\(MultipartFormData.CRLF)")) + encoded.append(MultipartResponseParsing.CRLF) return encoded } @@ -186,13 +181,13 @@ fileprivate struct BodyPart: Hashable { if let filename = self.filename { headers += "; filename=\"\(filename)\"" } - headers += "\(MultipartFormData.CRLF)" + headers += "\r\n" if let contentType = self.contentType { - headers += "Content-Type: \(contentType)\(MultipartFormData.CRLF)" + headers += "Content-Type: \(contentType)\r\n" } - headers += "\(MultipartFormData.CRLF)" + headers += "\r\n" return headers } diff --git a/apollo-ios/Sources/Apollo/MultipartResponseDeferParser.swift b/apollo-ios/Sources/Apollo/MultipartResponseDeferParser.swift deleted file mode 100644 index f0b62daef..000000000 --- a/apollo-ios/Sources/Apollo/MultipartResponseDeferParser.swift +++ /dev/null @@ -1,88 +0,0 @@ -import Foundation -#if !COCOAPODS -import ApolloAPI -#endif - -struct MultipartResponseDeferParser: MultipartResponseSpecificationParser { - public enum ParsingError: Swift.Error, LocalizedError, Equatable { - case unsupportedContentType(type: String) - case cannotParseChunkData - case cannotParsePayloadData - - public var errorDescription: String? { - switch self { - - case let .unsupportedContentType(type): - return "Unsupported content type: 'application/graphql-response+json' or 'application/json' are supported, received '\(type)'." - case .cannotParseChunkData: - return "The chunk data could not be parsed." - case .cannotParsePayloadData: - return "The payload data could not be parsed." - } - } - } - - private enum DataLine { - case contentHeader(directives: [String]) - case json(object: JSONObject) - case unknown - - init(_ value: String) { - self = Self.parse(value) - } - - private static func parse(_ dataLine: String) -> DataLine { - if let directives = dataLine.parseContentTypeDirectives() { - return .contentHeader(directives: directives) - } - - if - let data = dataLine.data(using: .utf8), - let jsonObject = try? JSONSerializationFormat.deserialize(data: data) as JSONObject - { - return .json(object: jsonObject) - } - - return .unknown - } - } - - static let protocolSpec: String = "deferSpec=20220824" - - static func parse(_ chunk: String) -> Result { - for dataLine in chunk.components(separatedBy: Self.dataLineSeparator.description) { - switch DataLine(dataLine.trimmingCharacters(in: .newlines)) { - case let .contentHeader(directives): - guard directives.contains(where: { $0.isValidGraphQLContentType }) else { - return .failure(ParsingError.unsupportedContentType(type: directives.joined(separator: ";"))) - } - - case let .json(object): - guard object.isPartialResponse || object.isIncrementalResponse else { - return .failure(ParsingError.cannotParsePayloadData) - } - - guard let serialized: Data = try? JSONSerializationFormat.serialize(value: object) else { - return .failure(ParsingError.cannotParsePayloadData) - } - - return .success(serialized) - - case .unknown: - return .failure(ParsingError.cannotParseChunkData) - } - } - - return .success(nil) - } -} - -fileprivate extension JSONObject { - var isPartialResponse: Bool { - self.keys.contains("data") && self.keys.contains("hasNext") - } - - var isIncrementalResponse: Bool { - self.keys.contains("incremental") && self.keys.contains("hasNext") - } -} diff --git a/apollo-ios/Sources/Apollo/MultipartResponseParsingInterceptor.swift b/apollo-ios/Sources/Apollo/MultipartResponseParsingInterceptor.swift deleted file mode 100644 index 07c766994..000000000 --- a/apollo-ios/Sources/Apollo/MultipartResponseParsingInterceptor.swift +++ /dev/null @@ -1,217 +0,0 @@ -import Foundation -#if !COCOAPODS -import ApolloAPI -#endif - -/// Parses multipart response data into chunks and forwards each on to the next interceptor. -public struct MultipartResponseParsingInterceptor: ApolloInterceptor { - - public enum ParsingError: Error, LocalizedError, Equatable { - case noResponseToParse - @available(*, deprecated, message: "Use the more specific `missingMultipartBoundary` and `invalidMultipartProtocol` errors instead.") - case cannotParseResponse - case cannotParseResponseData - case missingMultipartBoundary - case invalidMultipartProtocol - - public var errorDescription: String? { - switch self { - case .noResponseToParse: - return "There is no response to parse. Check the order of your interceptors." - case .cannotParseResponse: - return "The response data could not be parsed." - case .cannotParseResponseData: - return "The response data could not be parsed." - case .missingMultipartBoundary: - return "Missing multipart boundary in the response 'content-type' header." - case .invalidMultipartProtocol: - return "Missing, or unknown, multipart specification protocol in the response 'content-type' header." - } - } - } - - private static let responseParsers: [String: any MultipartResponseSpecificationParser.Type] = [ - MultipartResponseSubscriptionParser.protocolSpec: MultipartResponseSubscriptionParser.self, - MultipartResponseDeferParser.protocolSpec: MultipartResponseDeferParser.self, - ] - - public var id: String = UUID().uuidString - - public init() { } - - public func interceptAsync( - chain: any RequestChain, - request: HTTPRequest, - response: HTTPResponse?, - completion: @escaping GraphQLResultHandler - ) where Operation : GraphQLOperation { - - guard let response else { - chain.handleErrorAsync( - ParsingError.noResponseToParse, - request: request, - response: response, - completion: completion - ) - return - } - - if !response.httpResponse.isMultipart { - chain.proceedAsync( - request: request, - response: response, - interceptor: self, - completion: completion - ) - return - } - - let multipartComponents = response.httpResponse.multipartHeaderComponents - - guard let boundary = multipartComponents.boundary else { - chain.handleErrorAsync( - ParsingError.missingMultipartBoundary, - request: request, - response: response, - completion: completion - ) - return - } - - guard - let `protocol` = multipartComponents.protocol, - let parser = Self.responseParsers[`protocol`] - else { - chain.handleErrorAsync( - ParsingError.invalidMultipartProtocol, - request: request, - response: response, - completion: completion - ) - return - } - - guard let dataString = String(data: response.rawData, encoding: .utf8) else { - chain.handleErrorAsync( - ParsingError.cannotParseResponseData, - request: request, - response: response, - completion: completion - ) - return - } - - // Parsing Notes: - // - // Multipart messages arriving here may consist of more than one chunk, but they are always - // expected to be complete chunks. Downstream protocol specification parsers are only built - // to handle the protocol specific message formats, i.e.: data between the multipart delimiter. - let boundaryDelimiter = Self.boundaryDelimiter(with: boundary) - for chunk in dataString.components(separatedBy: boundaryDelimiter) { - if chunk.isEmpty || chunk.isDashBoundaryPrefix || chunk.isMultipartNewLine { continue } - - switch parser.parse(chunk) { - case let .success(data): - // Some chunks can be successfully parsed but do not require to be passed on to the next - // interceptor, such as an HTTP subscription heartbeat message. - if let data { - let response = HTTPResponse( - response: response.httpResponse, - rawData: data, - parsedResponse: nil - ) - - chain.proceedAsync( - request: request, - response: response, - interceptor: self, - completion: completion - ) - } - - case let .failure(parserError): - chain.handleErrorAsync( - parserError, - request: request, - response: response, - completion: completion - ) - } - } - } -} - -// MARK: Specification Parser Protocol - -/// A protocol that multipart response parsers must conform to in order to be added to the list of -/// available response specification parsers. -protocol MultipartResponseSpecificationParser { - /// The specification string matching what is expected to be received in the `Content-Type` header - /// in an HTTP response. - static var protocolSpec: String { get } - - /// Called to process each chunk in a multipart response. - /// - /// The return value is a `Result` type that indicates whether the chunk was successfully parsed - /// or not. It is possible to return `.success` with a `nil` data value. This should only happen - /// when the chunk was successfully parsed but there is no action to take on the message, such as - /// a heartbeat message. Successful results with a `nil` data value will not be returned to the - /// user. - static func parse(_ chunk: String) -> Result -} - -extension MultipartResponseSpecificationParser { - static var dataLineSeparator: StaticString { "\r\n\r\n" } -} - -// MARK: Helpers - -extension MultipartResponseParsingInterceptor { - static func boundaryDelimiter(with boundary: String) -> String { - "\r\n--\(boundary)" - } - - static func closeBoundaryDelimiter(with boundary: String) -> String { - boundaryDelimiter(with: boundary) + "--" - } -} - -extension String { - fileprivate var isDashBoundaryPrefix: Bool { self == "--" } - fileprivate var isMultipartNewLine: Bool { self == "\r\n" } - - /// Returns the range of a complete multipart chunk. - func multipartRange(using boundary: String) -> String.Index? { - // The end boundary marker indicates that no further chunks will follow so if this delimiter - // if found then include the delimiter in the index. Search for this first. - let closeBoundaryDelimiter = MultipartResponseParsingInterceptor.closeBoundaryDelimiter(with: boundary) - if let endIndex = range(of: closeBoundaryDelimiter, options: .backwards)?.upperBound { - return endIndex - } - - // A chunk boundary indicates there may still be more chunks to follow so the index need not - // include the chunk boundary in the index. - let boundaryDelimiter = MultipartResponseParsingInterceptor.boundaryDelimiter(with: boundary) - if let chunkIndex = range(of: boundaryDelimiter, options: .backwards)?.lowerBound { - return chunkIndex - } - - return nil - } - - func parseContentTypeDirectives() -> [String]? { - var lowercasedContentTypeHeader: StaticString { "content-type:" } - - guard lowercased().starts(with: lowercasedContentTypeHeader.description) else { - return nil - } - - return dropFirst(lowercasedContentTypeHeader.description.count) - .components(separatedBy: ";") - .map({ $0.trimmingCharacters(in: .whitespaces) }) - } - - var isValidGraphQLContentType: Bool { - self == "application/json" || self == "application/graphql-response+json" - } -} diff --git a/apollo-ios/Sources/Apollo/NetworkFetchInterceptor.swift b/apollo-ios/Sources/Apollo/NetworkFetchInterceptor.swift deleted file mode 100644 index 3ccdff498..000000000 --- a/apollo-ios/Sources/Apollo/NetworkFetchInterceptor.swift +++ /dev/null @@ -1,91 +0,0 @@ -import Foundation -#if !COCOAPODS -import ApolloAPI -#endif - -#warning("TODO: should be able to remove @unchecked once we get rid of currentTask and id.") -/// An interceptor which actually fetches data from the network. -public final class NetworkFetchInterceptor: ApolloInterceptor, @unchecked Sendable, Cancellable { - let client: URLSessionClient - @Atomic private var currentTask: URLSessionTask? - - public var id: String = UUID().uuidString - - /// Designated initializer. - /// - /// - Parameter client: The `URLSessionClient` to use to fetch data - public init(client: URLSessionClient) { - self.client = client - } - - public func interceptAsync( - chain: any RequestChain, - request: HTTPRequest, - response: HTTPResponse?, - completion: @escaping GraphQLResultHandler - ) { - let urlRequest: URLRequest - do { - urlRequest = try request.toURLRequest() - } catch { - chain.handleErrorAsync( - error, - request: request, - response: response, - completion: completion - ) - return - } - - let taskDescription = "\(Operation.operationType) \(Operation.operationName)" - let task = self.client.sendRequest(urlRequest, taskDescription: taskDescription) { [weak self] result in - guard let self = self else { - return - } - - defer { - if Operation.operationType != .subscription { - self.$currentTask.mutate { $0 = nil } - } - } - - guard !chain.isCancelled else { - return - } - - switch result { - case .failure(let error): - chain.handleErrorAsync( - error, - request: request, - response: response, - completion: completion - ) - - case .success(let (data, httpResponse)): - let response = HTTPResponse( - response: httpResponse, - rawData: data, - parsedResponse: nil - ) - - chain.proceedAsync( - request: request, - response: response, - interceptor: self, - completion: completion - ) - } - } - - self.$currentTask.mutate { $0 = task } - } - - public func cancel() { - guard let task = self.currentTask else { - return - } - - task.cancel() - } -} diff --git a/apollo-ios/Sources/Apollo/NetworkTransport.swift b/apollo-ios/Sources/Apollo/NetworkTransport.swift index f1c79c39a..36b5f2e20 100644 --- a/apollo-ios/Sources/Apollo/NetworkTransport.swift +++ b/apollo-ios/Sources/Apollo/NetworkTransport.swift @@ -4,7 +4,7 @@ import ApolloAPI #endif /// A network transport is responsible for sending GraphQL operations to a server. -public protocol NetworkTransport: AnyObject { +public protocol NetworkTransport: AnyObject, Sendable { /// Send a GraphQL operation to a server and return a response. /// @@ -15,82 +15,14 @@ public protocol NetworkTransport: AnyObject { /// - cachePolicy: The `CachePolicy` to use making this request. /// - contextIdentifier: [optional] A unique identifier for this request, to help with deduping cache hits for watchers. Defaults to `nil`. /// - context: [optional] A context that is being passed through the request chain. Defaults to `nil`. - /// - callbackQueue: The queue to call back on with the results. Should default to `.main`. - /// - completionHandler: A closure to call when a request completes. On `success` will contain the response received from the server. On `failure` will contain the error which occurred. - /// - Returns: An object that can be used to cancel an in progress request. + /// - Returns: A stream of `GraphQLResult`s for each response. func send( operation: Operation, cachePolicy: CachePolicy, contextIdentifier: UUID?, - context: (any RequestContext)?, - callbackQueue: DispatchQueue, - completionHandler: @escaping GraphQLResultHandler - ) -> any Cancellable + context: (any RequestContext)? + ) async throws -> AsyncThrowingStream, any Error> - /// The name of the client to send as a header value. - var clientName: String { get } - - /// The version of the client to send as a header value. - var clientVersion: String { get } -} - -public extension NetworkTransport { - - /// The field name for the Apollo Client Name header - static var headerFieldNameApolloClientName: String { - return "apollographql-client-name" - } - - /// The field name for the Apollo Client Version header - static var headerFieldNameApolloClientVersion: String { - return "apollographql-client-version" - } - - /// The default client name to use when setting up the `clientName` property - static var defaultClientName: String { - guard let identifier = Bundle.main.bundleIdentifier else { - return "apollo-ios-client" - } - - return "\(identifier)-apollo-ios" - } - - var clientName: String { - return Self.defaultClientName - } - - /// The default client version to use when setting up the `clientVersion` property. - static var defaultClientVersion: String { - var version = String() - if let shortVersion = Bundle.main.shortVersion { - version.append(shortVersion) - } - - if let buildNumber = Bundle.main.buildNumber { - if version.isEmpty { - version.append(buildNumber) - } else { - version.append("-\(buildNumber)") - } - } - - if version.isEmpty { - version = "(unknown)" - } - - return version - } - - var clientVersion: String { - return Self.defaultClientVersion - } - - /// Adds the Apollo client headers for this instance of `NetworkTransport` to the given request - /// - Parameter request: A mutable URLRequest to add the headers to. - func addApolloClientHeaders(to request: inout URLRequest) { - request.setValue(self.clientName, forHTTPHeaderField: Self.headerFieldNameApolloClientName) - request.setValue(self.clientVersion, forHTTPHeaderField: Self.headerFieldNameApolloClientVersion) - } } // MARK: - @@ -104,14 +36,10 @@ public protocol UploadingNetworkTransport: NetworkTransport { /// - operation: The operation to send /// - files: An array of `GraphQLFile` objects to send. /// - context: [optional] A context that is being passed through the request chain. - /// - callbackQueue: The queue to call back on with the results. Should default to `.main`. - /// - completionHandler: The completion handler to execute when the request completes or errors - /// - Returns: An object that can be used to cancel an in progress request. + /// - Returns: A stream of `GraphQLResult`s for each response. func upload( operation: Operation, files: [GraphQLFile], - context: (any RequestContext)?, - callbackQueue: DispatchQueue, - completionHandler: @escaping GraphQLResultHandler - ) -> any Cancellable + context: (any RequestContext)? + ) async throws -> AsyncThrowingStream, any Error> } diff --git a/apollo-ios/Sources/Apollo/RequestBodyCreator.swift b/apollo-ios/Sources/Apollo/RequestBodyCreator.swift index b9d36bed5..7ab7a5b28 100644 --- a/apollo-ios/Sources/Apollo/RequestBodyCreator.swift +++ b/apollo-ios/Sources/Apollo/RequestBodyCreator.swift @@ -2,29 +2,59 @@ import ApolloAPI #endif -public protocol RequestBodyCreator { +public protocol JSONRequestBodyCreator: Sendable { + #warning("TODO: replace with version that takes request after rewriting websocket") /// Creates a `JSONEncodableDictionary` out of the passed-in operation /// + /// - Note: This function only exists for supporting the soon-to-be-replaced `WebSocketTransport` + /// from the `ApolloWebSocket` package. Once that package is re-written, this function will likely + /// be deprecated. + /// /// - Parameters: - /// - operation: The operation to use + /// - operation: The `GraphQLOperation` to create the JSON body for. /// - sendQueryDocument: Whether or not to send the full query document. Should default to `true`. /// - autoPersistQuery: Whether to use auto-persisted query information. Should default to `false`. + /// - clientAwarenessMetadata: Metadata used by the + /// [client awareness](https://www.apollographql.com/docs/graphos/platform/insights/client-segmentation) + /// feature of GraphOS Studio. /// - Returns: The created `JSONEncodableDictionary` func requestBody( for operation: Operation, sendQueryDocument: Bool, - autoPersistQuery: Bool + autoPersistQuery: Bool, + clientAwarenessMetadata: ClientAwarenessMetadata ) -> JSONEncodableDictionary } // MARK: - Default Implementation -extension RequestBodyCreator { - +extension JSONRequestBodyCreator { + + /// Creates a `JSONEncodableDictionary` out of the passed-in request + /// + /// - Parameters: + /// - request: The `GraphQLRequest` to create the JSON body for. + /// - sendQueryDocument: Whether or not to send the full query document. Should default to `true`. + /// - autoPersistQuery: Whether to use auto-persisted query information. Should default to `false`. + /// - Returns: The created `JSONEncodableDictionary` + public func requestBody( + for request: Request, + sendQueryDocument: Bool, + autoPersistQuery: Bool + ) -> JSONEncodableDictionary { + self.requestBody( + for: request.operation, + sendQueryDocument: sendQueryDocument, + autoPersistQuery: autoPersistQuery, + clientAwarenessMetadata: request.clientAwarenessMetadata + ) + } + public func requestBody( for operation: Operation, sendQueryDocument: Bool, - autoPersistQuery: Bool + autoPersistQuery: Bool, + clientAwarenessMetadata: ClientAwarenessMetadata ) -> JSONEncodableDictionary { var body: JSONEncodableDictionary = [ "operationName": Operation.operationName, @@ -55,8 +85,16 @@ extension RequestBodyCreator { } } -// Helper struct to create requests independently of HTTP operations. -public struct ApolloRequestBodyCreator: RequestBodyCreator { +public struct DefaultRequestBodyCreator: JSONRequestBodyCreator { // Internal init methods cannot be used in public methods public init() { } } + +// MARK: - Deprecations + +@available(*, deprecated, renamed: "JSONRequestBodyCreator") +public typealias RequestBodyCreator = JSONRequestBodyCreator + +// Helper struct to create requests independently of HTTP operations. +@available(*, deprecated, renamed: "DefaultRequestBodyCreator") +public typealias ApolloRequestBodyCreator = DefaultRequestBodyCreator diff --git a/apollo-ios/Sources/Apollo/RequestChain.swift b/apollo-ios/Sources/Apollo/RequestChain.swift index 7cb62dde6..f32b0ac1c 100644 --- a/apollo-ios/Sources/Apollo/RequestChain.swift +++ b/apollo-ios/Sources/Apollo/RequestChain.swift @@ -1,46 +1,277 @@ +import Foundation + #if !COCOAPODS -import ApolloAPI + import ApolloAPI #endif -public protocol RequestChain: Cancellable { - func kickoff( - request: HTTPRequest, - completion: @escaping @Sendable (Result, any Error>) -> Void - ) where Operation : GraphQLOperation - - @available(*, deprecated, renamed: "proceedAsync(request:response:interceptor:completion:)") - func proceedAsync( - request: HTTPRequest, - response: HTTPResponse?, - completion: @escaping GraphQLResultHandler - ) where Operation : GraphQLOperation - - func proceedAsync( - request: HTTPRequest, - response: HTTPResponse?, - interceptor: any ApolloInterceptor, - completion: @escaping GraphQLResultHandler - ) where Operation : GraphQLOperation - - func cancel() - - func retry( - request: HTTPRequest, - completion: @escaping GraphQLResultHandler - ) where Operation : GraphQLOperation - - func handleErrorAsync( - _ error: any Error, - request: HTTPRequest, - response: HTTPResponse?, - completion: @escaping GraphQLResultHandler - ) where Operation : GraphQLOperation - - func returnValueAsync( - for request: HTTPRequest, - value: GraphQLResult, - completion: @escaping GraphQLResultHandler - ) where Operation : GraphQLOperation - - var isCancelled: Bool { get } +public struct RequestChainRetry: Swift.Error { + public let request: Request + + public init( + request: Request, + ) { + self.request = request + } +} + +public enum RequestChainError: Swift.Error, LocalizedError { + case missingParsedResult + case noResults + + public var errorDescription: String? { + switch self { + case .missingParsedResult: + return + "Request chain completed with no `parsedResult` value. A request chain must include an interceptor that parses the response data." + case .noResults: + return + "Request chain completed request with no results emitted. This can occur if the network returns a success response with no body content, or if an interceptor fails to pass on the emitted results" + } + } + +} + +struct RequestChain: Sendable { + + private let urlSession: any ApolloURLSession + private let interceptors: [any ApolloInterceptor] + private let cacheInterceptor: any CacheInterceptor + private let errorInterceptor: (any ApolloErrorInterceptor)? + + typealias Operation = Request.Operation + typealias ResultStream = AsyncThrowingStream, any Error> + + /// Creates a chain with the given interceptor array. + /// + /// - Parameters: + /// - interceptors: The array of interceptors to use. + /// - callbackQueue: The `DispatchQueue` to call back on when an error or result occurs. + /// Defaults to `.main`. + init( + urlSession: any ApolloURLSession, + interceptors: [any ApolloInterceptor], + cacheInterceptor: any CacheInterceptor, + errorInterceptor: (any ApolloErrorInterceptor)? + ) { + self.urlSession = urlSession + self.interceptors = interceptors + self.cacheInterceptor = cacheInterceptor + self.errorInterceptor = errorInterceptor + } + + /// Kicks off the request from the beginning of the interceptor array. + /// + /// - Parameters: + /// - request: The request to send. + func kickoff( + request: Request + ) -> ResultStream where Operation: GraphQLQuery { + return doInRetryingAsyncThrowingStream(request: request) { request, continuation in + let didYieldCacheData = try await handleCacheRead(request: request, continuation: continuation) + + if request.cachePolicy.shouldFetchFromNetwork(hadSuccessfulCacheRead: didYieldCacheData) { + try await kickoffRequestInterceptors(for: request, continuation: continuation) + } + } + } + + func kickoff( + request: Request + ) -> ResultStream { + return doInRetryingAsyncThrowingStream(request: request) { request, continuation in + try await kickoffRequestInterceptors(for: request, continuation: continuation) + } + } + + private func doInRetryingAsyncThrowingStream( + request: Request, + _ body: @escaping @Sendable (Request, ResultStream.Continuation) async throws -> Void + ) -> ResultStream { + return AsyncThrowingStream { continuation in + let task = Task { + do { + try await doHandlingRetries(request: request) { request in + try await body(request, continuation) + } + + } catch { + continuation.finish(throwing: error) + } + + continuation.finish() + } + + continuation.onTermination = { _ in + task.cancel() + } + } + } + + private func doHandlingRetries( + request: Request, + _ body: @escaping @Sendable (Request) async throws -> Void + ) async throws { + do { + try await body(request) + + } catch let error as RequestChainRetry { + try await self.doHandlingRetries(request: error.request, body) + } + } + + private func handleCacheRead( + request: Request, + continuation: ResultStream.Continuation + ) async throws -> Bool where Operation: GraphQLQuery { + guard request.cachePolicy.shouldAttemptCacheRead else { + return false + } + + do { + let cacheData = try await cacheInterceptor.readCacheData(for: request.operation) + continuation.yield(cacheData) + return true + + } catch { + if case .returnCacheDataDontFetch = request.cachePolicy { + throw error + } + return false + } + } + + private func kickoffRequestInterceptors( + for initialRequest: Request, + continuation: ResultStream.Continuation + ) async throws { + nonisolated(unsafe) var finalRequest: Request! + var next: @Sendable (Request) async throws -> InterceptorResultStream = { request in + finalRequest = request + return try await executeNetworkFetch(request: request) + } + + for interceptor in interceptors.reversed() { + let tempNext = next + + next = { request in + try await interceptor.intercept(request: request, next: tempNext) + } + } + + let resultStream = try await next(initialRequest) + + var didEmitResult: Bool = false + + for try await result in resultStream.getResults() { + guard let result = result.parsedResult else { + throw RequestChainError.missingParsedResult + } + + try await writeToCacheIfNecessary(result: result, for: finalRequest) + + continuation.yield(result.result) + didEmitResult = true + } + + guard didEmitResult else { + throw RequestChainError.noResults + } + } + + private func executeNetworkFetch( + request: Request + ) async throws -> InterceptorResultStream { + return InterceptorResultStream( + stream: AsyncThrowingStream { continuation in + let task = Task { + do { + let (chunks, response) = try await urlSession.chunks(for: request) + + guard let response = response as? HTTPURLResponse else { + preconditionFailure() +#warning( + "Throw error instead of precondition failure? Look into if it is possible for this to even occur." + ) + } + + for try await chunk in chunks { + continuation.yield( + InterceptorResult( + response: response, + rawResponseChunk: chunk as! Data + ) + ) + } + + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + + continuation.onTermination = { _ in task.cancel() } + } + ) + } + + private func writeToCacheIfNecessary( + result: InterceptorResult.ParsedResult, + for request: Request + ) async throws { + guard let records = result.cacheRecords, + result.result.source == .server, + request.cachePolicy.shouldAttemptCacheWrite + else { + return + } + + try await cacheInterceptor.writeCacheData( + cacheRecords: records, + for: request.operation, + with: result.result + ) + } +} + +extension CachePolicy { + fileprivate var shouldAttemptCacheRead: Bool { + switch self { + case .fetchIgnoringCacheCompletely, + .fetchIgnoringCacheData: + return false + + case .returnCacheDataAndFetch, + .returnCacheDataDontFetch, + .returnCacheDataElseFetch: + return true + } + } + + fileprivate var shouldAttemptCacheWrite: Bool { + switch self { + case .fetchIgnoringCacheCompletely, + .returnCacheDataDontFetch: + return false + + case .fetchIgnoringCacheData, + .returnCacheDataAndFetch, + .returnCacheDataElseFetch: + return true + } + } + + func shouldFetchFromNetwork(hadSuccessfulCacheRead: Bool) -> Bool { + switch self { + case .returnCacheDataDontFetch: + return false + + case .fetchIgnoringCacheData, + .returnCacheDataAndFetch, + .fetchIgnoringCacheCompletely: + return true + + case .returnCacheDataElseFetch: + return !hadSuccessfulCacheRead + } + } } diff --git a/apollo-ios/Sources/Apollo/RequestChainNetworkTransport.swift b/apollo-ios/Sources/Apollo/RequestChainNetworkTransport.swift index 47c394d4d..279ef3d3e 100644 --- a/apollo-ios/Sources/Apollo/RequestChainNetworkTransport.swift +++ b/apollo-ios/Sources/Apollo/RequestChainNetworkTransport.swift @@ -5,7 +5,7 @@ import ApolloAPI /// An implementation of `NetworkTransport` which creates a `RequestChain` object /// for each item sent through it. -open class RequestChainNetworkTransport: NetworkTransport { +public final class RequestChainNetworkTransport: NetworkTransport, Sendable { /// The interceptor provider to use when constructing a request chain let interceptorProvider: any InterceptorProvider @@ -15,15 +15,17 @@ open class RequestChainNetworkTransport: NetworkTransport { /// Any additional HTTP headers that should be added to **every** request, such as an API key or a language setting. /// - /// If a header should only be added to _certain_ requests, or if its value might differ between requests, - /// you should add that header in an interceptor instead. + /// If a header should only be added to _certain_ requests, or if its value might differ between + /// requests, you should add that header in an interceptor instead. /// /// Defaults to an empty dictionary. - public private(set) var additionalHeaders: [String: String] - - /// Set to `true` if Automatic Persisted Queries should be used to send a query hash instead of the full query body by default. - public let autoPersistQueries: Bool - + public let additionalHeaders: [String: String] + + /// A configuration struct used by a `GraphQLRequest` to configure the usage of + /// [Automatic Persisted Queries (APQs).](https://www.apollographql.com/docs/apollo-server/performance/apq) + /// By default, APQs are disabled. + public let apqConfig: AutoPersistedQueryConfiguration + /// Set to `true` if you want to use `GET` instead of `POST` for queries. /// /// This can improve performance if your GraphQL server uses a CDN (Content Delivery Network) @@ -33,14 +35,11 @@ open class RequestChainNetworkTransport: NetworkTransport { /// /// Defaults to `false`. public let useGETForQueries: Bool - - /// Set to `true` to use `GET` instead of `POST` for a retry of a persisted query. - public let useGETForPersistedQueryRetry: Bool - - /// The `RequestBodyCreator` object used to build your `URLRequest`. + + /// The `JSONRequestBodyCreator` object used to build your `URLRequest`'s JSON body. /// - /// Defaults to an ``ApolloRequestBodyCreator`` initialized with the default configuration. - public var requestBodyCreator: any RequestBodyCreator + /// Defaults to a ``DefaultRequestBodyCreator`` initialized with the default configuration. + public let requestBodyCreator: any JSONRequestBodyCreator private let sendEnhancedClientAwareness: Bool @@ -49,36 +48,30 @@ open class RequestChainNetworkTransport: NetworkTransport { /// - Parameters: /// - interceptorProvider: The interceptor provider to use when constructing a request chain /// - endpointURL: The GraphQL endpoint URL to use - /// - additionalHeaders: Any additional headers that should be automatically added to every request. Defaults to - /// an empty dictionary. - /// - autoPersistQueries: Pass `true` if Automatic Persisted Queries should be used to send a query hash instead - /// of the full query body by default. Defaults to `false`. - /// - requestBodyCreator: The `RequestBodyCreator` object to use to build your `URLRequest`. Defaults to the - /// provided `ApolloRequestBodyCreator` implementation. - /// - useGETForQueries: Pass `true` if you want to use `GET` instead of `POST` for queries, for example to take - /// advantage of a CDN. Defaults to `false`. - /// - useGETForPersistedQueryRetry: Pass `true` to use `GET` instead of `POST` for a retry of a persisted query. - /// Defaults to `false`. + /// - additionalHeaders: Any additional headers that should be automatically added to every request. Defaults to an empty dictionary. + /// - apqConfig: A configuration struct used by a `GraphQLRequest` to configure the usage of + /// [Automatic Persisted Queries (APQs).](https://www.apollographql.com/docs/apollo-server/performance/apq) By default, APQs + /// are disabled. + /// - requestBodyCreator: The `RequestBodyCreator` object to use to build your `URLRequest`. Defaults to the provided `ApolloRequestBodyCreator` implementation. + /// - useGETForQueries: Pass `true` if you want to use `GET` instead of `POST` for queries, for example to take advantage of a CDN. Defaults to `false`. /// - sendEnhancedClientAwareness: Specifies whether client library metadata is sent in each request `extensions` /// key. Client library metadata is the Apollo iOS library name and version. Defaults to `true`. public init( interceptorProvider: any InterceptorProvider, endpointURL: URL, additionalHeaders: [String: String] = [:], - autoPersistQueries: Bool = false, - requestBodyCreator: any RequestBodyCreator = ApolloRequestBodyCreator(), + apqConfig: AutoPersistedQueryConfiguration = .init(), + requestBodyCreator: any JSONRequestBodyCreator = DefaultRequestBodyCreator(), useGETForQueries: Bool = false, - useGETForPersistedQueryRetry: Bool = false, sendEnhancedClientAwareness: Bool = true ) { self.interceptorProvider = interceptorProvider self.endpointURL = endpointURL self.additionalHeaders = additionalHeaders - self.autoPersistQueries = autoPersistQueries + self.apqConfig = apqConfig self.requestBodyCreator = requestBodyCreator self.useGETForQueries = useGETForQueries - self.useGETForPersistedQueryRetry = useGETForPersistedQueryRetry self.sendEnhancedClientAwareness = sendEnhancedClientAwareness } @@ -92,75 +85,58 @@ open class RequestChainNetworkTransport: NetworkTransport { /// - contextIdentifier: [optional] A unique identifier for this request, to help with deduping cache hits for watchers. Should default to `nil`. /// - context: [optional] A context that is being passed through the request chain. Should default to `nil`. /// - Returns: The constructed request. - open func constructRequest( + public func constructRequest( for operation: Operation, cachePolicy: CachePolicy, contextIdentifier: UUID? = nil, context: (any RequestContext)? = nil - ) -> HTTPRequest { - let request = JSONRequest( + ) -> JSONRequest { + var request = JSONRequest( operation: operation, graphQLEndpoint: self.endpointURL, contextIdentifier: contextIdentifier, clientName: self.clientName, clientVersion: self.clientVersion, - additionalHeaders: self.additionalHeaders, cachePolicy: cachePolicy, context: context, - autoPersistQueries: self.autoPersistQueries, + apqConfig: self.apqConfig, useGETForQueries: self.useGETForQueries, - useGETForPersistedQueryRetry: self.useGETForPersistedQueryRetry, requestBodyCreator: self.requestBodyCreator, sendEnhancedClientAwareness: self.sendEnhancedClientAwareness ) - - if Operation.operationType == .subscription { - request.addHeader( - name: "Accept", - value: "multipart/mixed;\(MultipartResponseSubscriptionParser.protocolSpec),application/graphql-response+json,application/json" - ) - - } else { - request.addHeader( - name: "Accept", - value: "multipart/mixed;\(MultipartResponseDeferParser.protocolSpec),application/graphql-response+json,application/json" - ) - } - + request.addHeaders(self.additionalHeaders) return request } // MARK: - NetworkTransport Conformance - - public var clientName = RequestChainNetworkTransport.defaultClientName - public var clientVersion = RequestChainNetworkTransport.defaultClientVersion - + public func send( operation: Operation, - cachePolicy: CachePolicy = .default, - contextIdentifier: UUID? = nil, - context: (any RequestContext)? = nil, - callbackQueue: DispatchQueue = .main, - completionHandler: @escaping @Sendable (Result, any Error>) -> Void) -> any Cancellable { - - let chain = makeChain(operation: operation, callbackQueue: callbackQueue) + cachePolicy: CachePolicy, + contextIdentifier: UUID?, + context: (any RequestContext)? + ) async throws -> AsyncThrowingStream, any Error> { let request = self.constructRequest( for: operation, cachePolicy: cachePolicy, contextIdentifier: contextIdentifier, context: context) - - chain.kickoff(request: request, completion: completionHandler) - return chain + + let chain = makeChain(for: request) + + return chain.kickoff(request: request) } - private func makeChain( - operation: Operation, - callbackQueue: DispatchQueue = .main - ) -> any RequestChain { - let interceptors = self.interceptorProvider.interceptors(for: operation) - let chain = InterceptorRequestChain(interceptors: interceptors, callbackQueue: callbackQueue) - chain.additionalErrorHandler = self.interceptorProvider.additionalErrorInterceptor(for: operation) + private func makeChain( + for request: Request + ) -> RequestChain { + let operation = request.operation + let chain = RequestChain( + urlSession: interceptorProvider.urlSession(for: operation), + interceptors: interceptorProvider.interceptors(for: operation), + cacheInterceptor: interceptorProvider.cacheInterceptor(for: operation), + errorInterceptor: interceptorProvider.errorInterceptor(for: operation) + ) return chain } @@ -182,33 +158,41 @@ extension RequestChainNetworkTransport: UploadingNetworkTransport { for operation: Operation, with files: [GraphQLFile], context: (any RequestContext)? = nil, - manualBoundary: String? = nil) -> HTTPRequest { - - UploadRequest( - graphQLEndpoint: self.endpointURL, - operation: operation, - clientName: self.clientName, - clientVersion: self.clientVersion, - additionalHeaders: self.additionalHeaders, - files: files, - manualBoundary: manualBoundary, - context: context, - requestBodyCreator: self.requestBodyCreator, - sendEnhancedClientAwareness: self.sendEnhancedClientAwareness - ) + manualBoundary: String? = nil + ) -> UploadRequest { + var request = UploadRequest( + operation: operation, + graphQLEndpoint: self.endpointURL, + clientName: self.clientName, + clientVersion: self.clientVersion, + files: files, + multipartBoundary: manualBoundary, + context: context, + requestBodyCreator: self.requestBodyCreator, + sendEnhancedClientAwareness: self.sendEnhancedClientAwareness + ) + request.additionalHeaders = self.additionalHeaders + return request } public func upload( operation: Operation, files: [GraphQLFile], - context: (any RequestContext)?, - callbackQueue: DispatchQueue = .main, - completionHandler: @escaping GraphQLResultHandler - ) -> any Cancellable { - + context: (any RequestContext)? + ) async throws -> AsyncThrowingStream, any Error> { let request = self.constructUploadRequest(for: operation, with: files, context: context) - let chain = makeChain(operation: operation, callbackQueue: callbackQueue) - chain.kickoff(request: request, completion: completionHandler) - return chain + let chain = makeChain(for: request) + return chain.kickoff(request: request) } + + // MARK: - Deprecations + + /// Set to `true` if Automatic Persisted Queries should be used to send a query hash instead of + /// the full query body by default. + @available(*, deprecated, message: "Use apqConfig.autoPersistQueries instead.") + public var autoPersistQueries: Bool { apqConfig.autoPersistQueries } + + /// Set to `true` to use `GET` instead of `POST` for a retry of a persisted query. + @available(*, deprecated, message: "Use apqConfig.useGETForPersistedQueryRetry instead.") + public var useGETForPersistedQueryRetry: Bool { apqConfig.useGETForPersistedQueryRetry } } diff --git a/apollo-ios/Sources/Apollo/ResponseCodeInterceptor.swift b/apollo-ios/Sources/Apollo/ResponseCodeInterceptor.swift deleted file mode 100644 index a74290b33..000000000 --- a/apollo-ios/Sources/Apollo/ResponseCodeInterceptor.swift +++ /dev/null @@ -1,82 +0,0 @@ -import Foundation -#if !COCOAPODS -import ApolloAPI -#endif - -/// An interceptor to check the response code returned with a request. -public struct ResponseCodeInterceptor: ApolloInterceptor { - - public var id: String = UUID().uuidString - - public enum ResponseCodeError: Error, LocalizedError { - case invalidResponseCode(response: HTTPURLResponse?, rawData: Data?) - - public var errorDescription: String? { - switch self { - case .invalidResponseCode(let response, let rawData): - var errorStrings = [String]() - if let code = response?.statusCode { - errorStrings.append("Received a \(code) error.") - } else { - errorStrings.append("Did not receive a valid status code.") - } - - if - let data = rawData, - let dataString = String(bytes: data, encoding: .utf8) { - errorStrings.append("Data returned as a String was:") - errorStrings.append(dataString) - } else { - errorStrings.append("Data was nil or could not be transformed into a string.") - } - - return errorStrings.joined(separator: " ") - } - } - - public var graphQLError: GraphQLError? { - switch self { - case .invalidResponseCode(_, let rawData): - if let jsonRawData = rawData, - let jsonData = try? (JSONSerialization.jsonObject(with: jsonRawData, options: .allowFragments) as! JSONValue), - let jsonObject = try? JSONObject(_jsonValue: jsonData) - { - return GraphQLError(jsonObject) - } - return nil - } - } - } - - /// Designated initializer - public init() {} - - public func interceptAsync( - chain: any RequestChain, - request: HTTPRequest, - response: HTTPResponse?, - completion: @escaping GraphQLResultHandler - ) { - guard response?.httpResponse.isSuccessful == true else { - let error = ResponseCodeError.invalidResponseCode( - response: response?.httpResponse, - rawData: response?.rawData - ) - - chain.handleErrorAsync( - error, - request: request, - response: response, - completion: completion - ) - return - } - - chain.proceedAsync( - request: request, - response: response, - interceptor: self, - completion: completion - ) - } -} diff --git a/apollo-ios/Sources/Apollo/ResponseParsing/BaseResponseExecutionHandler.swift b/apollo-ios/Sources/Apollo/ResponseParsing/BaseResponseExecutionHandler.swift new file mode 100644 index 000000000..8f123e705 --- /dev/null +++ b/apollo-ios/Sources/Apollo/ResponseParsing/BaseResponseExecutionHandler.swift @@ -0,0 +1,109 @@ +#if !COCOAPODS +@_spi(Internal) import ApolloAPI +#endif + +struct BaseResponseExecutionHandler: Sendable { + + let responseBody: JSONObject + let rootKey: CacheReference + let variables: GraphQLOperation.Variables? + + init( + responseBody: JSONObject, + rootKey: CacheReference, + variables: GraphQLOperation.Variables? + ) { + self.responseBody = responseBody + self.rootKey = rootKey + self.variables = variables + } + + /// Call this function when you want to execute on an entire operation and its response data. + /// This function should also be called to execute on the partial (initial) response of an + /// operation with deferred selection sets. + func execute< + Accumulator: GraphQLResultAccumulator, + Data: RootSelectionSet + >( + selectionSet: Data.Type, + with accumulator: Accumulator + ) async throws -> Accumulator.FinalResult? { + guard let dataEntry = responseBody["data"] as? JSONObject else { + return nil + } + + return try await executor.execute( + selectionSet: Data.self, + on: dataEntry, + withRootCacheReference: rootKey, + variables: variables, + accumulator: accumulator + ) + } + + /// Call this function to execute on a specific selection set and its incremental response data. + /// This is typically used when executing on deferred selections. + func execute< + Accumulator: GraphQLResultAccumulator, + Operation: GraphQLOperation + >( + selectionSet: any Deferrable.Type, + in operation: Operation.Type, + with accumulator: Accumulator + ) async throws -> Accumulator.FinalResult? { + guard let dataEntry = responseBody["data"] as? JSONObject else { + return nil + } + + return try await executor.execute( + selectionSet: selectionSet, + in: Operation.self, + on: dataEntry, + withRootCacheReference: rootKey, + variables: variables, + accumulator: accumulator + ) + } + + var executor: GraphQLExecutor { + GraphQLExecutor(executionSource: NetworkResponseExecutionSource()) + } + + func parseErrors() -> [GraphQLError]? { + guard let errorsEntry = self.responseBody["errors"] as? [JSONObject] else { + return nil + } + + return errorsEntry.map { + GraphQLError($0) + } + } + + func parseExtensions() -> JSONObject? { + return self.responseBody["extensions"] as? JSONObject + } +} + +// MARK: - Equatable Conformance + +#warning("TODO: Do we need this?") +extension BaseResponseExecutionHandler: Equatable { + static func == (lhs: BaseResponseExecutionHandler, rhs: BaseResponseExecutionHandler) -> Bool { + AnySendableHashable.equatableCheck(lhs.responseBody, rhs.responseBody) && + lhs.rootKey == rhs.rootKey && + AnySendableHashable.equatableCheck( + lhs.variables?._jsonEncodableObject._jsonValue, + rhs.variables?._jsonEncodableObject._jsonValue + ) + } +} + +// MARK: - Hashable Conformance + +extension BaseResponseExecutionHandler: Hashable { + func hash(into hasher: inout Hasher) { + hasher.combine(responseBody) + hasher.combine(rootKey) + hasher.combine(variables?._jsonEncodableObject._jsonValue) + } +} diff --git a/apollo-ios/Sources/Apollo/ResponseParsing/IncrementalResponseExecutionHandler.swift b/apollo-ios/Sources/Apollo/ResponseParsing/IncrementalResponseExecutionHandler.swift new file mode 100644 index 000000000..dbdac53dd --- /dev/null +++ b/apollo-ios/Sources/Apollo/ResponseParsing/IncrementalResponseExecutionHandler.swift @@ -0,0 +1,174 @@ +import Foundation +#if !COCOAPODS +import ApolloAPI +#endif + +public enum IncrementalResponseError: Error, LocalizedError, Equatable { + case missingExistingData + case missingPath + case missingLabel + case missingDeferredSelectionSetType(String, String) + + public var errorDescription: String? { + switch self { + case .missingExistingData: + return "Incremental response must be returned after initial response." + case .missingPath: + return "Incremental responses must have a 'path' key." + + case .missingLabel: + return "Incremental responses must have a 'label' key." + + case let .missingDeferredSelectionSetType(label, path): + return "The operation does not have a deferred selection set for label '\(label)' at field path '\(path)'." + } + } +} + +extension JSONResponseParser { + /// Represents an incremental GraphQL response received from a server. + struct IncrementalResponseExecutionHandler { + + private let base: BaseResponseExecutionHandler + + init( + responseBody: JSONObject, + operationVariables: Operation.Variables? + ) throws { + guard let path = responseBody["path"] as? [JSONValue] else { + throw IncrementalResponseError.missingPath + } + + let rootKey = try CacheReference.rootCacheReference(for: Operation.operationType, path: path) + + self.base = BaseResponseExecutionHandler( + responseBody: responseBody, + rootKey: rootKey, + variables: operationVariables + ) + } + + #warning("Fix Docs") + /// Parses the response into a `IncrementalGraphQLResult` and a `RecordSet` depending on the cache policy. The result + /// can be used to merge into a partial result and the `RecordSet` can be merged into a local cache. + /// + /// - Returns: A tuple of a `IncrementalGraphQLResult` and an optional `RecordSet`. + /// + /// - Parameter cachePolicy: Used to determine whether a cache `RecordSet` is returned. A cache policy that does + /// not read or write to the cache will return a `nil` cache `RecordSet`. + func execute( + includeCacheRecords: Bool + ) async throws -> (IncrementalGraphQLResult, RecordSet?) { + switch includeCacheRecords { + case false: + return (try await parseIncrementalResultOmittingCacheRecords(), nil) + + case true: + return try await parseIncrementalResultIncludingCacheRecords() + } + } + + private func parseIncrementalResultIncludingCacheRecords() + async throws -> (IncrementalGraphQLResult, RecordSet?) { + let accumulator = zip( + DataDictMapper(), + ResultNormalizerFactory.networkResponseDataNormalizer(), + GraphQLDependencyTracker() + ) + + var cacheKeys: RecordSet? = nil + let result = try await makeResult { deferrableSelectionSetType in + let executionResult = try await base.execute( + selectionSet: deferrableSelectionSetType, + in: Operation.self, + with: accumulator + ) + cacheKeys = executionResult?.1 + + return (executionResult?.0, executionResult?.2) + } + + return (result, cacheKeys) + } + + private func parseIncrementalResultOmittingCacheRecords() async throws -> IncrementalGraphQLResult { + let accumulator = DataDictMapper() + let result = try await makeResult { deferrableSelectionSetType in + let executionResult = try await base.execute( + selectionSet: deferrableSelectionSetType, + in: Operation.self, + with: accumulator + ) + + return (executionResult, nil) + } + + return result + } + + fileprivate func makeResult( + executor: ((any Deferrable.Type) async throws -> (data: DataDict?, dependentKeys: Set?)) + ) async throws -> IncrementalGraphQLResult { + guard let path = base.responseBody["path"] as? [JSONValue] else { + throw IncrementalResponseError.missingPath + } + guard let label = base.responseBody["label"] as? String else { + throw IncrementalResponseError.missingLabel + } + + let pathComponents: [PathComponent] = path.compactMap(PathComponent.init) + let fieldPath = pathComponents.fieldPath + + guard let selectionSetType = Operation.deferredSelectionSetType( + withLabel: label, + atFieldPath: fieldPath + ) as? (any Deferrable.Type) else { + throw IncrementalResponseError.missingDeferredSelectionSetType(label, fieldPath.joined(separator: ".")) + } + + let executionResult = try await executor(selectionSetType) + let selectionSet: (any SelectionSet)? + + if let data = executionResult.data { + selectionSet = selectionSetType.init(_dataDict: data) + } else { + selectionSet = nil + } + + return IncrementalGraphQLResult( + label: label, + path: pathComponents, + data: selectionSet, + extensions: base.parseExtensions(), + errors: base.parseErrors(), + dependentKeys: executionResult.dependentKeys + ) + } + } +} + +extension CacheReference { + fileprivate static func rootCacheReference( + for operationType: GraphQLOperationType, + path: [JSONValue] + ) throws -> CacheReference { + var keys: [String] = [rootCacheReference(for: operationType).key] + for component in path { + keys.append(try String(_jsonValue: component)) + } + + return CacheReference(keys.joined(separator: ".")) + } +} + +extension [PathComponent] { + fileprivate var fieldPath: [String] { + return self.compactMap({ pathComponent in + if case let .field(name) = pathComponent { + return name + } + + return nil + }) + } +} diff --git a/apollo-ios/Sources/Apollo/ResponseParsing/JSONResponseParser.swift b/apollo-ios/Sources/Apollo/ResponseParsing/JSONResponseParser.swift new file mode 100644 index 000000000..5ec7a9c79 --- /dev/null +++ b/apollo-ios/Sources/Apollo/ResponseParsing/JSONResponseParser.swift @@ -0,0 +1,167 @@ +import Foundation +#if !COCOAPODS +import ApolloAPI +#endif + +public struct JSONResponseParser: Sendable { + + public enum Error: Swift.Error, LocalizedError { + case couldNotParseToJSON(data: Data) + case missingMultipartBoundary + case invalidMultipartProtocol + + public var errorDescription: String? { + switch self { + case .couldNotParseToJSON(let data): + var errorStrings = [String]() + errorStrings.append("Could not parse data to JSON format.") + if let dataString = String(bytes: data, encoding: .utf8) { + errorStrings.append("Data received as a String was:") + errorStrings.append(dataString) + } else { + errorStrings.append("Data of count \(data.count) also could not be parsed into a String.") + } + + return errorStrings.joined(separator: " ") + + case .missingMultipartBoundary: + return "Missing multipart boundary in the response 'content-type' header." + + case .invalidMultipartProtocol: + return "Missing, or unknown, multipart specification protocol in the response 'content-type' header." + } + } + } + + public typealias ParsedResult = (GraphQLResult, RecordSet?) + + let response: HTTPURLResponse + let operationVariables: Operation.Variables? + let multipartHeader: HTTPURLResponse.MultipartHeaderComponents + let includeCacheRecords: Bool + + init( + response: HTTPURLResponse, + operationVariables: Operation.Variables?, + includeCacheRecords: Bool + ) { + self.response = response + self.multipartHeader = response.multipartHeaderComponents + self.operationVariables = operationVariables + self.includeCacheRecords = includeCacheRecords + } + + public func parse( + dataChunk: Data, + mergingIncrementalItemsInto existingResult: ParsedResult? + ) async throws -> ParsedResult? { + switch response.isMultipart { + case false: + return try await parseSingleResponse(data: dataChunk) + + case true: + guard multipartHeader.boundary != nil else { + throw Error.missingMultipartBoundary + } + + guard + let `protocol` = multipartHeader.`protocol`, + let parser = multipartParser(forProtocol: `protocol`) + else { + throw Error.invalidMultipartProtocol + } + + guard let parsedChunk = try parser.parse(multipartChunk: dataChunk) else { + return nil + } + + try Task.checkCancellation() + + if let incrementalItems = parsedChunk["incremental"] as? [JSONObject] { + guard let existingResult else { + throw IncrementalResponseError.missingExistingData + } + + return try await executeIncrementalResponses( + merging: incrementalItems, + into: existingResult + ) + + } else { + // Parse initial chunk + return try await parseSingleResponse(body: parsedChunk) + } + } + } + + // MARK: - Single Response Parsing + + public func parseSingleResponse(data: Data) async throws -> ParsedResult { + guard + let body = try? JSONSerializationFormat.deserialize(data: data) as JSONObject + else { + throw Error.couldNotParseToJSON(data: data) + } + + return try await parseSingleResponse(body: body) + } + + public func parseSingleResponse(body: JSONObject) async throws -> ParsedResult { + let executionHandler = SingleResponseExecutionHandler( + responseBody: body, + operationVariables: operationVariables + ) + return try await executionHandler.execute(includeCacheRecords: includeCacheRecords) + } + + // MARK: - Multipart Response Parsing + + private func multipartParser( + forProtocol protocol: String + ) -> (any MultipartResponseSpecificationParser.Type)? { + switch `protocol` { + case MultipartResponseSubscriptionParser.protocolSpec: + return MultipartResponseSubscriptionParser.self + + case MultipartResponseDeferParser.protocolSpec: + return MultipartResponseDeferParser.self + + default: return nil + } + } + + private func executeIncrementalResponses( + merging incrementalItems: [JSONObject], + into existingResult: ParsedResult + ) async throws -> ParsedResult { + var currentResult = existingResult.0 + var currentCacheRecords = existingResult.1 + + for item in incrementalItems { + let (incrementalResult, incrementalCacheRecords) = try await executeIncrementalItem( + itemBody: item + ) + try Task.checkCancellation() + + currentResult = try currentResult.merging(incrementalResult) + + if let incrementalCacheRecords { + currentCacheRecords?.merge(records: incrementalCacheRecords) + } + } + + return (currentResult, currentCacheRecords) + } + + private func executeIncrementalItem( + itemBody: JSONObject + ) async throws -> (IncrementalGraphQLResult, RecordSet?) { + let incrementalExecutionHandler = try IncrementalResponseExecutionHandler( + responseBody: itemBody, + operationVariables: operationVariables + ) + + return try await incrementalExecutionHandler.execute(includeCacheRecords: includeCacheRecords) + } + +} diff --git a/apollo-ios/Sources/Apollo/ResponseParsing/MultipartResponseDeferParser.swift b/apollo-ios/Sources/Apollo/ResponseParsing/MultipartResponseDeferParser.swift new file mode 100644 index 000000000..e63a86fe3 --- /dev/null +++ b/apollo-ios/Sources/Apollo/ResponseParsing/MultipartResponseDeferParser.swift @@ -0,0 +1,86 @@ +import Foundation +#if !COCOAPODS +import ApolloAPI +#endif + +struct MultipartResponseDeferParser: MultipartResponseSpecificationParser { + public enum ParsingError: Swift.Error, LocalizedError, Equatable { + case unsupportedContentType(type: String) + case cannotParseChunkData + case cannotParsePayloadData + + public var errorDescription: String? { + switch self { + + case let .unsupportedContentType(type): + return "Unsupported content type: application/json is required but got \(type)." + case .cannotParseChunkData: + return "The chunk data could not be parsed." + case .cannotParsePayloadData: + return "The payload data could not be parsed." + } + } + } + + private enum DataLine { + case contentHeader(type: MultipartResponseParsing.ContentTypeDataLine) + case json(object: JSONObject) + case unknown + + init(_ value: Data) { + self = Self.parse(value) + } + + private static func parse(_ dataLine: Data) -> DataLine { + if let contentType = MultipartResponseParsing.ContentTypeDataLine(dataLine) { + return .contentHeader(type: contentType) + } + + if let jsonObject = try? JSONSerializationFormat.deserialize(data: dataLine) as JSONObject { + return .json(object: jsonObject) + } + + return .unknown + } + } + + static let SupportedContentTypes: [MultipartResponseParsing.ContentTypeDataLine] = [ + .applicationJSON, + .applicationGraphQLResponseJSON + ] + + static let protocolSpec: String = "deferSpec=20220824" + + static func parse(multipartChunk chunk: Data) throws -> JSONObject? { + var dataLines = MultipartResponseParsing.DataLineIterator(data: chunk) + while let dataLine = dataLines.next() { + switch DataLine(dataLine) { + case let .contentHeader(contentType): + guard SupportedContentTypes.contains(contentType) else { + throw ParsingError.unsupportedContentType(type: contentType.valueString) + } + + case let .json(object): + guard object.isPartialResponse || object.isIncrementalResponse else { + throw ParsingError.cannotParsePayloadData + } + return object + + case .unknown: + throw ParsingError.cannotParseChunkData + } + } + + return nil + } +} + +fileprivate extension JSONObject { + var isPartialResponse: Bool { + self.keys.contains("data") && self.keys.contains("hasNext") + } + + var isIncrementalResponse: Bool { + self.keys.contains("incremental") && self.keys.contains("hasNext") + } +} diff --git a/apollo-ios/Sources/Apollo/ResponseParsing/MultipartResponseSpecificationParser.swift b/apollo-ios/Sources/Apollo/ResponseParsing/MultipartResponseSpecificationParser.swift new file mode 100644 index 000000000..b52ade9ed --- /dev/null +++ b/apollo-ios/Sources/Apollo/ResponseParsing/MultipartResponseSpecificationParser.swift @@ -0,0 +1,131 @@ +import Foundation +import ApolloAPI + +/// A protocol that multipart response parsers must conform to in order to be added to the list of +/// available response specification parsers. +protocol MultipartResponseSpecificationParser { + /// The specification string matching what is expected to be received in the `Content-Type` header + /// in an HTTP response. + static var protocolSpec: String { get } + + /// Called to process each chunk in a multipart response. + /// + /// - Parameter data: Response data for a single chunk of a multipart response. + /// - Returns: A ``JSONObject`` for the parsed chunk. + /// It is possible for parsing to succeed and return a `nil` data value. + /// This should only happen when the chunk was successfully parsed but there is no + /// action to take on the message, such as a heartbeat message. Successful results + /// with a `nil` data value will not be returned to the user. + static func parse(multipartChunk: Data) throws -> JSONObject? + +} + +// MARK: - Multipart Parsing Helpers + +// In compliance with (RFC 1341 Multipart Content-Type)[https://www.w3.org/Protocols/rfc1341/7_2_Multipart.html] +enum MultipartResponseParsing { + /// Carriage Return Line Feed + static let CRLF: Data = Data([0x0D, 0x0A]) // "\r\n" + + static let Delimeter: Data = CRLF + [0x2D, 0x2D] // "\r\n--" + + /// The delimeter that signifies the end of a multipart response. + /// + /// This should immediately follow a Delimeter + Boundary. + static let CloseDelimeter: Data = Data([0x2D, 0x2D]) // "--" + + + struct DataLineIterator: IteratorProtocol { + /// A double carriage return. Used as the seperator between data lines within a multipart response chunk + private static let DataLineSeparator: Data = CRLF + CRLF // "\r\n\r\n" + + var data: Data + + mutating func next() -> Data? { + guard !data.isEmpty else { return nil } + guard let separatorRange = data.firstRange(of: Self.DataLineSeparator) else { + return data + } + + let slice = data[data.startIndex.. + if let directiveSeparatorRange = line.firstRange(of: Self.DirectiveSeparator) { + valueRange = keySeparatorRange.endIndex.. Bool { + for key in Key.AllowedValues { + if line.starts(with: key) { + return true + } + } + return false + } + + var valueString: String { + switch self { + case .applicationJSON: + return String(data: Self.Value.ApplicationJSON, encoding: .utf8)! + + case .applicationGraphQLResponseJSON: + return String(data: Self.Value.ApplicationGraphQLResponseJSON, encoding: .utf8)! + + case .unknown(value: let value): + return String(data: value, encoding: .utf8)! + } + } + } + +} diff --git a/apollo-ios/Sources/Apollo/MultipartResponseSubscriptionParser.swift b/apollo-ios/Sources/Apollo/ResponseParsing/MultipartResponseSubscriptionParser.swift similarity index 57% rename from apollo-ios/Sources/Apollo/MultipartResponseSubscriptionParser.swift rename to apollo-ios/Sources/Apollo/ResponseParsing/MultipartResponseSubscriptionParser.swift index 4f668785e..060942ef0 100644 --- a/apollo-ios/Sources/Apollo/MultipartResponseSubscriptionParser.swift +++ b/apollo-ios/Sources/Apollo/ResponseParsing/MultipartResponseSubscriptionParser.swift @@ -15,7 +15,7 @@ struct MultipartResponseSubscriptionParser: MultipartResponseSpecificationParser switch self { case let .unsupportedContentType(type): - return "Unsupported content type: 'application/graphql-response+json' or 'application/json' are supported, received '\(type)'." + return "Unsupported content type: application/json is required but got \(type)." case .cannotParseChunkData: return "The chunk data could not be parsed." case let .irrecoverableError(message): @@ -30,30 +30,26 @@ struct MultipartResponseSubscriptionParser: MultipartResponseSpecificationParser private enum DataLine { case heartbeat - case contentHeader(directives: [String]) + case contentHeader(type: MultipartResponseParsing.ContentTypeDataLine) case json(object: JSONObject) case unknown - init(_ value: String) { + init(_ value: Data) { self = Self.parse(value) } - private static func parse(_ dataLine: String) -> DataLine { - var contentTypeHeader: StaticString { "content-type:" } - var heartbeat: StaticString { "{}" } + static let HeartbeatMessage: Data = Data([0x7b, 0x7d]) // "{}" - if dataLine == heartbeat.description { + private static func parse(_ dataLine: Data) -> DataLine { + if dataLine == HeartbeatMessage { return .heartbeat } - if let directives = dataLine.parseContentTypeDirectives() { - return .contentHeader(directives: directives) + if let contentType = MultipartResponseParsing.ContentTypeDataLine(dataLine) { + return .contentHeader(type: contentType) } - if - let data = dataLine.data(using: .utf8), - let jsonObject = try? JSONSerializationFormat.deserialize(data: data) as JSONObject - { + if let jsonObject = try? JSONSerializationFormat.deserialize(data: dataLine) as JSONObject { return .json(object: jsonObject) } @@ -61,18 +57,24 @@ struct MultipartResponseSubscriptionParser: MultipartResponseSpecificationParser } } + static let SupportedContentTypes: [MultipartResponseParsing.ContentTypeDataLine] = [ + .applicationJSON, + .applicationGraphQLResponseJSON + ] + static let protocolSpec: String = "subscriptionSpec=1.0" - static func parse(_ chunk: String) -> Result { - for dataLine in chunk.components(separatedBy: Self.dataLineSeparator.description) { - switch DataLine(dataLine.trimmingCharacters(in: .newlines)) { + static func parse(multipartChunk chunk: Data) throws -> JSONObject? { + var dataLines = MultipartResponseParsing.DataLineIterator(data: chunk) + while let dataLine = dataLines.next() { + switch DataLine(dataLine) { case .heartbeat: // Periodically sent by the router - noop break - case let .contentHeader(directives): - guard directives.contains(where: { $0.isValidGraphQLContentType }) else { - return .failure(ParsingError.unsupportedContentType(type: directives.joined(separator: ";"))) + case let .contentHeader(contentType): + guard SupportedContentTypes.contains(contentType) else { + throw ParsingError.unsupportedContentType(type: contentType.valueString) } case let .json(object): @@ -81,35 +83,34 @@ struct MultipartResponseSubscriptionParser: MultipartResponseSpecificationParser let errors = errors as? [JSONObject], let message = errors.first?["message"] as? String else { - return .failure(ParsingError.cannotParseErrorData) + throw ParsingError.cannotParseErrorData } - return .failure(ParsingError.irrecoverableError(message: message)) + throw ParsingError.irrecoverableError(message: message) } if let payload = object.payload, !(payload is NSNull) { guard - let payload = payload as? JSONObject, - let data: Data = try? JSONSerializationFormat.serialize(value: payload) + let payload = payload as? JSONObject else { - return .failure(ParsingError.cannotParsePayloadData) + throw ParsingError.cannotParsePayloadData } - return .success(data) + return payload } // 'errors' is optional because valid payloads don't have transport errors. // `errors` can be null because it's taken to be the same as optional. // `payload` is optional because the heartbeat message does not contain a payload field. // `payload` can be null such as in the case of a transport error or future use (TBD). - return .success(nil) + return nil case .unknown: - return .failure(ParsingError.cannotParseChunkData) + throw ParsingError.cannotParseChunkData } } - return .success(nil) + return nil } } diff --git a/apollo-ios/Sources/Apollo/ResponseParsing/SingleResponseExecutionHandler.swift b/apollo-ios/Sources/Apollo/ResponseParsing/SingleResponseExecutionHandler.swift new file mode 100644 index 000000000..f45f55f1e --- /dev/null +++ b/apollo-ios/Sources/Apollo/ResponseParsing/SingleResponseExecutionHandler.swift @@ -0,0 +1,110 @@ +#if !COCOAPODS +import ApolloAPI +#endif + +extension JSONResponseParser { + + struct SingleResponseExecutionHandler { + private let base: BaseResponseExecutionHandler + + init( + responseBody: JSONObject, + operationVariables: Operation.Variables? + ) { + self.base = BaseResponseExecutionHandler( + responseBody: responseBody, + rootKey: CacheReference.rootCacheReference(for: Operation.operationType), + variables: operationVariables + ) + } + + /// Runs GraphQLExecution over the "data" of the JSON response object and converts it into a + /// `GraphQLResult` and optional `RecordSet`. + /// The result can be sent to a completion block for a request and the `RecordSet` can be + /// merged into a local cache. + /// + /// - Returns: A `GraphQLResult` and optional `RecordSet`. + func execute( + includeCacheRecords: Bool + ) async throws -> ParsedResult { + switch includeCacheRecords { + case false: + return (try await parseResultOmittingCacheRecords(), nil) + + case true: + return try await parseResultIncludingCacheRecords() + } + } + + /// Parses a response into a `GraphQLResult` and a `RecordSet`. The result can be sent to a completion block for a + /// request and the `RecordSet` can be merged into a local cache. + /// + /// - Returns: A `GraphQLResult` and a `RecordSet`. + public func parseResultIncludingCacheRecords() async throws -> ParsedResult { + let accumulator = zip( + DataDictMapper(), + ResultNormalizerFactory.networkResponseDataNormalizer(), + GraphQLDependencyTracker() + ) + let executionResult = try await base.execute( + selectionSet: Operation.Data.self, + with: accumulator + ) + + let result = makeResult( + data: executionResult?.0 != nil ? Operation.Data(_dataDict: executionResult!.0) : nil, + dependentKeys: executionResult?.2 + ) + + return (result, executionResult?.1) + } + + /// Parses a response into a `GraphQLResult` for use without the cache. This parsing does not + /// create dependent keys or a `RecordSet` for the cache. + /// + /// This is faster than `parseResult()` and should be used when cache the response is not needed. + public func parseResultOmittingCacheRecords() async throws -> GraphQLResult { + let accumulator = DataDictMapper() + let data = try await base.execute( + selectionSet: Operation.Data.self, + with: accumulator + ) + + return makeResult( + data: data != nil ? Operation.Data(_dataDict: data!) : nil, + dependentKeys: nil + ) + } + + private func makeResult( + data: Operation.Data?, + dependentKeys: Set? + ) -> GraphQLResult { + #warning("TODO: Do we need to make sure that there is either data or errors in the result?") + return GraphQLResult( + data: data, + extensions: base.parseExtensions(), + errors: base.parseErrors(), + source: .server, + dependentKeys: dependentKeys + ) + } + } +} + +// MARK: - Equatable Conformance + +#warning("TODO: do we need these?") +//extension GraphQLResponse: Equatable where Data: Equatable { +// public static func == (lhs: GraphQLResponse, rhs: GraphQLResponse) -> Bool { +// lhs.base == rhs.base +// } +//} +// +//// MARK: - Hashable Conformance +// +//extension GraphQLResponse: Hashable { +// public func hash(into hasher: inout Hasher) { +// hasher.combine(base) +// } +//} diff --git a/apollo-ios/Sources/Apollo/TaskData.swift b/apollo-ios/Sources/Apollo/TaskData.swift deleted file mode 100644 index 2cae3c159..000000000 --- a/apollo-ios/Sources/Apollo/TaskData.swift +++ /dev/null @@ -1,35 +0,0 @@ -import Foundation - -/// A wrapper for data about a particular task handled by `URLSessionClient` -public class TaskData { - - public let rawCompletion: URLSessionClient.RawCompletion? - public let completionBlock: URLSessionClient.Completion - private(set) var data: Data = Data() - private(set) var response: HTTPURLResponse? = nil - - init(rawCompletion: URLSessionClient.RawCompletion?, - completionBlock: @escaping URLSessionClient.Completion) { - self.rawCompletion = rawCompletion - self.completionBlock = completionBlock - } - - func append(additionalData: Data) { - self.data.append(additionalData) - } - - func reset(data: Data?) { - guard let data, !data.isEmpty else { - self.data = Data() - return - } - - self.data = data - } - - func responseReceived(response: URLResponse) { - if let httpResponse = response as? HTTPURLResponse { - self.response = httpResponse - } - } -} diff --git a/apollo-ios/Sources/Apollo/URLSessionClient.swift b/apollo-ios/Sources/Apollo/URLSessionClient.swift deleted file mode 100644 index 71db6d6df..000000000 --- a/apollo-ios/Sources/Apollo/URLSessionClient.swift +++ /dev/null @@ -1,365 +0,0 @@ -import Foundation - -/// A class to handle URL Session calls that will support background execution, -/// but still (mostly) use callbacks for its primary method of communication. -/// -/// **NOTE:** Delegate methods implemented here are not documented inline because -/// Apple has their own documentation for them. Please consult Apple's -/// documentation for how the delegate methods work and what needs to be overridden -/// and handled within your app, particularly in regards to what needs to be called -/// when for background sessions. -open class URLSessionClient: - NSObject, - @unchecked Sendable, - URLSessionDelegate, URLSessionTaskDelegate, URLSessionDataDelegate { -#warning("TODO: temp @unchecked Sendable to move forward; is not yet thread safe") - public enum URLSessionClientError: Error, LocalizedError { - case noHTTPResponse(request: URLRequest?) - case sessionBecameInvalidWithoutUnderlyingError - case dataForRequestNotFound(request: URLRequest?) - case networkError(data: Data, response: HTTPURLResponse?, underlying: any Error) - case sessionInvalidated - case missingMultipartBoundary - case cannotParseBoundaryData - - public var errorDescription: String? { - switch self { - case .noHTTPResponse(let request): - return "The request did not receive an HTTP response. Request: \(String(describing: request))" - case .sessionBecameInvalidWithoutUnderlyingError: - return "The URL session became invalid, but no underlying error was returned." - case .dataForRequestNotFound(let request): - return "URLSessionClient was not able to locate the stored data for request \(String(describing: request))" - case .networkError(_, _, let underlyingError): - return "A network error occurred: \(underlyingError.localizedDescription)" - case .sessionInvalidated: - return "Attempting to create a new request after the session has been invalidated!" - case .missingMultipartBoundary: - return "A multipart HTTP response was received without specifying a boundary!" - case .cannotParseBoundaryData: - return "Cannot parse the multipart boundary data!" - } - } - } - - /// A completion block to be called when the raw task has completed, with the raw information from the session - public typealias RawCompletion = (Data?, HTTPURLResponse?, (any Error)?) -> Void - - /// A completion block returning a result. On `.success` it will contain a tuple with non-nil `Data` and its corresponding `HTTPURLResponse`. On `.failure` it will contain an error. - public typealias Completion = (Result<(Data, HTTPURLResponse), any Error>) -> Void - - @Atomic private var tasks: [Int: TaskData] = [:] - - /// The raw URLSession being used for this client - open private(set) var session: URLSession! - - @Atomic private var hasBeenInvalidated: Bool = false - - private var hasNotBeenInvalidated: Bool { - !self.hasBeenInvalidated - } - - /// Designated initializer. - /// - /// - Parameters: - /// - sessionConfiguration: The `URLSessionConfiguration` to use to set up the URL session. - /// - callbackQueue: [optional] The `OperationQueue` to tell the URL session to call back to this class on, which will in turn call back to your class. Defaults to `.main`. - /// - sessionDescription: [optional] A human-readable string that you can use for debugging purposes. - public init(sessionConfiguration: URLSessionConfiguration = .default, - callbackQueue: OperationQueue? = .main, - sessionDescription: String? = nil) { - super.init() - - let session = URLSession(configuration: sessionConfiguration, - delegate: self, - delegateQueue: callbackQueue) - session.sessionDescription = sessionDescription - self.session = session - } - - /// Cleans up and invalidates everything related to this session client. - /// - /// NOTE: This must be called from the `deinit` of anything holding onto this client in order to break a retain cycle with the delegate. - public func invalidate() { - self.$hasBeenInvalidated.mutate { $0 = true } - func cleanup() { - self.session = nil - self.clearAllTasks() - } - - guard let session = self.session else { - // Session's already gone, just cleanup. - cleanup() - return - } - - session.invalidateAndCancel() - cleanup() - } - - /// Clears underlying dictionaries of any data related to a particular task identifier. - /// - /// - Parameter identifier: The identifier of the task to clear. - open func clear(task identifier: Int) { - self.$tasks.mutate { _ = $0.removeValue(forKey: identifier) } - } - - /// Clears underlying dictionaries of any data related to all tasks. - /// - /// Mostly useful for cleanup and/or after invalidation of the `URLSession`. - open func clearAllTasks() { - guard !self.tasks.isEmpty else { - // Nothing to clear - return - } - - self.$tasks.mutate { $0.removeAll() } - } - - /// The main method to perform a request. - /// - /// - Parameters: - /// - request: The request to perform. - /// - taskDescription: [optional] A description to add to the `URLSessionTask` for debugging purposes. - /// - rawTaskCompletionHandler: [optional] A completion handler to call once the raw task is done, so if an Error requires access to the headers, the user can still access these. - /// - completion: A completion handler to call when the task has either completed successfully or failed. - /// - /// - Returns: The created URLSession task, already resumed, because nobody ever remembers to call `resume()`. - @discardableResult - open func sendRequest(_ request: URLRequest, - taskDescription: String? = nil, - rawTaskCompletionHandler: RawCompletion? = nil, - completion: @escaping Completion) -> URLSessionTask { - guard self.hasNotBeenInvalidated else { - completion(.failure(URLSessionClientError.sessionInvalidated)) - return URLSessionTask() - } - - let task = self.session.dataTask(with: request) - task.taskDescription = taskDescription - - let taskData = TaskData(rawCompletion: rawTaskCompletionHandler, - completionBlock: completion) - - self.$tasks.mutate { $0[task.taskIdentifier] = taskData } - - task.resume() - - return task - } - - @discardableResult - open func sendRequest(_ request: URLRequest, - rawTaskCompletionHandler: RawCompletion? = nil, - completion: @escaping Completion) -> URLSessionTask { - sendRequest( - request, - taskDescription: nil, - rawTaskCompletionHandler: rawTaskCompletionHandler, - completion: completion - ) - } - - /// Cancels a given task and clears out its underlying data. - /// - /// NOTE: You will not receive any kind of "This was cancelled" error when this is called. - /// - /// - Parameter task: The task you wish to cancel. - open func cancel(task: URLSessionTask) { - self.clear(task: task.taskIdentifier) - task.cancel() - } - - // MARK: - URLSessionDelegate - - open func urlSession(_ session: URLSession, didBecomeInvalidWithError error: (any Error)?) { - let finalError = error ?? URLSessionClientError.sessionBecameInvalidWithoutUnderlyingError - for task in self.tasks.values { - task.completionBlock(.failure(finalError)) - } - - self.clearAllTasks() - } - - open func urlSession(_ session: URLSession, - task: URLSessionTask, - didFinishCollecting metrics: URLSessionTaskMetrics) { - // No default implementation - } - - open func urlSession(_ session: URLSession, - didReceive challenge: URLAuthenticationChallenge, - completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { - completionHandler(.performDefaultHandling, nil) - } - - #if os(iOS) || os(tvOS) || os(watchOS) - open func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { - // No default implementation - } - #endif - - // MARK: - NSURLSessionTaskDelegate - - open func urlSession(_ session: URLSession, - task: URLSessionTask, - didReceive challenge: URLAuthenticationChallenge, - completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { - completionHandler(.performDefaultHandling, nil) - } - - open func urlSession(_ session: URLSession, - taskIsWaitingForConnectivity task: URLSessionTask) { - // No default implementation - } - - open func urlSession(_ session: URLSession, - task: URLSessionTask, - didCompleteWithError error: (any Error)?) { - defer { - self.clear(task: task.taskIdentifier) - } - - guard let taskData = self.tasks[task.taskIdentifier] else { - // No completion blocks, the task has likely been cancelled. Bail out. - return - } - - let data = taskData.data - let response = taskData.response - - if let rawCompletion = taskData.rawCompletion { - rawCompletion(data, response, error) - } - - let completion = taskData.completionBlock - - if let finalError = error { - completion(.failure(URLSessionClientError.networkError(data: data, response: response, underlying: finalError))) - } else { - guard let finalResponse = response else { - completion(.failure(URLSessionClientError.noHTTPResponse(request: task.originalRequest))) - return - } - - completion(.success((data, finalResponse))) - } - } - - open func urlSession(_ session: URLSession, - task: URLSessionTask, - needNewBodyStream completionHandler: @escaping (InputStream?) -> Void) { - completionHandler(nil) - } - - open func urlSession(_ session: URLSession, - task: URLSessionTask, - didSendBodyData bytesSent: Int64, - totalBytesSent: Int64, - totalBytesExpectedToSend: Int64) { - // No default implementation - } - - open func urlSession(_ session: URLSession, - task: URLSessionTask, - willBeginDelayedRequest request: URLRequest, - completionHandler: @escaping (URLSession.DelayedRequestDisposition, URLRequest?) -> Void) { - completionHandler(.continueLoading, request) - } - - open func urlSession(_ session: URLSession, - task: URLSessionTask, - willPerformHTTPRedirection response: HTTPURLResponse, - newRequest request: URLRequest, - completionHandler: @escaping (URLRequest?) -> Void) { - completionHandler(request) - } - - // MARK: - URLSessionDataDelegate - - open func urlSession( - _ session: URLSession, - dataTask: URLSessionDataTask, - didReceive data: Data - ) { - guard dataTask.state != .canceling else { - // Task is in the process of cancelling, don't bother handling its data. - return - } - - guard let taskData = self.tasks[dataTask.taskIdentifier] else { - assertionFailure("No data found for task \(dataTask.taskIdentifier), cannot append received data") - return - } - - taskData.append(additionalData: data) - - if let httpResponse = dataTask.response as? HTTPURLResponse, httpResponse.isMultipart { - guard let boundary = httpResponse.multipartHeaderComponents.boundary else { - taskData.completionBlock(.failure(URLSessionClientError.missingMultipartBoundary)) - return - } - - // Parsing Notes: - // - // Multipart messages are parsed here only to look for complete chunks to pass on to the downstream - // parsers. Any leftover data beyond a delimited chunk is held back for more data to arrive. - // - // Do not return `.failure` here simply because there was no boundary delimiter found; the - // data may still be arriving. If the request ends without more data arriving it will get handled - // in urlSession(_:task:didCompleteWithError:). - guard - let dataString = String(data: taskData.data, encoding: .utf8), - let lastBoundaryDelimiterIndex = dataString.multipartRange(using: boundary), - let boundaryData = dataString.prefix(upTo: lastBoundaryDelimiterIndex).data(using: .utf8) - else { - return - } - - let remainingData = dataString.suffix(from: lastBoundaryDelimiterIndex).data(using: .utf8) - taskData.reset(data: remainingData) - - if let rawCompletion = taskData.rawCompletion { - rawCompletion(boundaryData, httpResponse, nil) - } - - taskData.completionBlock(.success((boundaryData, httpResponse))) - } - } - - open func urlSession(_ session: URLSession, - dataTask: URLSessionDataTask, - didBecome streamTask: URLSessionStreamTask) { - // No default implementation - } - - open func urlSession(_ session: URLSession, - dataTask: URLSessionDataTask, - didBecome downloadTask: URLSessionDownloadTask) { - // No default implementation - } - - open func urlSession(_ session: URLSession, - dataTask: URLSessionDataTask, - willCacheResponse proposedResponse: CachedURLResponse, - completionHandler: @escaping (CachedURLResponse?) -> Void) { - completionHandler(proposedResponse) - } - - open func urlSession(_ session: URLSession, - dataTask: URLSessionDataTask, - didReceive response: URLResponse, - completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { - defer { - completionHandler(.allow) - } - - self.$tasks.mutate { - guard let taskData = $0[dataTask.taskIdentifier] else { - return - } - - taskData.responseReceived(response: response) - } - } -} diff --git a/apollo-ios/Sources/Apollo/UploadRequest.swift b/apollo-ios/Sources/Apollo/UploadRequest.swift index a47d960a9..c85f978e3 100644 --- a/apollo-ios/Sources/Apollo/UploadRequest.swift +++ b/apollo-ios/Sources/Apollo/UploadRequest.swift @@ -4,11 +4,29 @@ import ApolloAPI #endif /// A request class allowing for a multipart-upload request. -open class UploadRequest: HTTPRequest { - - public let requestBodyCreator: any RequestBodyCreator +public struct UploadRequest: GraphQLRequest { + + /// The endpoint to make a GraphQL request to + public var graphQLEndpoint: URL + + /// The GraphQL Operation to execute + public var operation: Operation + + /// Any additional headers you wish to add to this request + public var additionalHeaders: [String: String] = [:] + + /// The `CachePolicy` to use for this request. + public var cachePolicy: CachePolicy + + /// [optional] A context that is being passed through the request chain. + public var context: (any RequestContext)? + + public let requestBodyCreator: any JSONRequestBodyCreator + public let files: [GraphQLFile] - public let manualBoundary: String? + + public let multipartBoundary: String + public let serializationFormat = JSONSerializationFormat.self private let sendEnhancedClientAwareness: Bool @@ -16,49 +34,41 @@ open class UploadRequest: HTTPRequest { /// Designated Initializer /// /// - Parameters: - /// - graphQLEndpoint: The endpoint to make a GraphQL request to /// - operation: The GraphQL Operation to execute + /// - graphQLEndpoint: The endpoint to make a GraphQL request to /// - clientName: The name of the client to send with the `"apollographql-client-name"` header /// - clientVersion: The version of the client to send with the `"apollographql-client-version"` header - /// - additionalHeaders: Any additional headers you wish to add by default to this request. Defaults to an empty dictionary. /// - files: The array of files to upload for all `Upload` parameters in the mutation. - /// - manualBoundary: [optional] A manual boundary to pass in. A default boundary will be used otherwise. Defaults to nil. + /// - multipartBoundary: [optional] A boundary to use for the multipart request. /// - context: [optional] A context that is being passed through the request chain. Should default to `nil`. /// - requestBodyCreator: An object conforming to the `RequestBodyCreator` protocol to assist with creating the request body. Defaults to the provided `ApolloRequestBodyCreator` implementation. public init( - graphQLEndpoint: URL, operation: Operation, - clientName: String, - clientVersion: String, - additionalHeaders: [String: String] = [:], + graphQLEndpoint: URL, + clientName: String? = Self.defaultClientName, + clientVersion: String? = Self.defaultClientVersion, files: [GraphQLFile], - manualBoundary: String? = nil, + multipartBoundary: String? = nil, context: (any RequestContext)? = nil, - requestBodyCreator: any RequestBodyCreator = ApolloRequestBodyCreator(), + requestBodyCreator: any JSONRequestBodyCreator = DefaultRequestBodyCreator(), sendEnhancedClientAwareness: Bool = true ) { + self.operation = operation + self.graphQLEndpoint = graphQLEndpoint + self.cachePolicy = .default + self.context = context self.requestBodyCreator = requestBodyCreator self.files = files - self.manualBoundary = manualBoundary - self.sendEnhancedClientAwareness = sendEnhancedClientAwareness - - super.init( - graphQLEndpoint: graphQLEndpoint, - operation: operation, - contentType: "multipart/form-data", - clientName: clientName, - clientVersion: clientVersion, - additionalHeaders: additionalHeaders, - context: context - ) + self.multipartBoundary = multipartBoundary ?? "apollo-ios.boundary.\(UUID().uuidString)" + + self.addApolloClientHeaders(clientName: clientName, clientVersion: clientVersion) + self.addHeader(name: "Content-Type", value: "multipart/form-data; boundary=\(self.multipartBoundary)") } - public override func toURLRequest() throws -> URLRequest { + public func toURLRequest() throws -> URLRequest { let formData = try self.requestMultipartFormData() - self.updateContentType(to: "multipart/form-data; boundary=\(formData.boundary)") - var request = try super.toURLRequest() + var request = createDefaultRequest() request.httpBody = try formData.encode() - request.httpMethod = GraphQLHTTPMethod.POST.rawValue return request } @@ -69,19 +79,13 @@ open class UploadRequest: HTTPRequest { /// /// - Throws: Any error arising from creating the form data /// - Returns: The created form data - open func requestMultipartFormData() throws -> MultipartFormData { - let formData: MultipartFormData - - if let boundary = manualBoundary { - formData = MultipartFormData(boundary: boundary) - } else { - formData = MultipartFormData() - } + public func requestMultipartFormData() throws -> MultipartFormData { + let formData = MultipartFormData(boundary: multipartBoundary) // Make sure all fields for files are set to null, or the server won't look // for the files in the rest of the form data let fieldsForFiles = Set(files.map { $0.fieldName }).sorted() - var fields = self.requestBodyCreator.requestBody(for: operation, + var fields = self.requestBodyCreator.requestBody(for: self, sendQueryDocument: true, autoPersistQuery: false) var variables = fields["variables"] as? JSONEncodableDictionary ?? JSONEncodableDictionary() @@ -100,7 +104,7 @@ open class UploadRequest: HTTPRequest { addEnhancedClientAwarenessExtension(to: &fields) } - let operationData = try serializationFormat.serialize(value: fields) + let operationData = try JSONSerializationFormat.serialize(value: fields) formData.appendPart(data: operationData, name: "operations") // If there are multiple files for the same field, make sure to include them with indexes for the field. If there are multiple files for different fields, just use the field name. @@ -126,7 +130,7 @@ open class UploadRequest: HTTPRequest { assert(sortedFiles.count == files.count, "Number of sorted files did not equal the number of incoming files - some field name has been left out.") - let mapData = try serializationFormat.serialize(value: map) + let mapData = try JSONSerializationFormat.serialize(value: map) formData.appendPart(data: mapData, name: "map") for (index, file) in sortedFiles.enumerated() { @@ -143,16 +147,22 @@ open class UploadRequest: HTTPRequest { // MARK: - Equtable/Hashable Conformance public static func == (lhs: UploadRequest, rhs: UploadRequest) -> Bool { - lhs as HTTPRequest == rhs as HTTPRequest && + lhs.graphQLEndpoint == rhs.graphQLEndpoint && + lhs.operation == rhs.operation && + lhs.additionalHeaders == rhs.additionalHeaders && + lhs.cachePolicy == rhs.cachePolicy && type(of: lhs.requestBodyCreator) == type(of: rhs.requestBodyCreator) && lhs.files == rhs.files && - lhs.manualBoundary == rhs.manualBoundary + lhs.multipartBoundary == rhs.multipartBoundary } - public override func hash(into hasher: inout Hasher) { - super.hash(into: &hasher) + public func hash(into hasher: inout Hasher) { + hasher.combine(graphQLEndpoint) + hasher.combine(operation) + hasher.combine(additionalHeaders) + hasher.combine(cachePolicy) hasher.combine("\(type(of: requestBodyCreator))") hasher.combine(files) - hasher.combine(manualBoundary) + hasher.combine(multipartBoundary) } } diff --git a/apollo-ios/Sources/ApolloAPI/GraphQLNullable.swift b/apollo-ios/Sources/ApolloAPI/GraphQLNullable.swift index 0df0899a7..b60f20b92 100644 --- a/apollo-ios/Sources/ApolloAPI/GraphQLNullable.swift +++ b/apollo-ios/Sources/ApolloAPI/GraphQLNullable.swift @@ -90,7 +90,7 @@ /// # See Also /// [GraphQLSpec - Input Values - Null Value](http://spec.graphql.org/October2021/#sec-Null-Value) @dynamicMemberLookup -public enum GraphQLNullable { +public enum GraphQLNullable: Sendable { /// The absence of a value. /// Functionally equivalent to `nil`. diff --git a/apollo-ios/Sources/ApolloAPI/GraphQLOperation.swift b/apollo-ios/Sources/ApolloAPI/GraphQLOperation.swift index 278f2c45e..deda7106b 100644 --- a/apollo-ios/Sources/ApolloAPI/GraphQLOperation.swift +++ b/apollo-ios/Sources/ApolloAPI/GraphQLOperation.swift @@ -157,7 +157,7 @@ public extension GraphQLSubscription { // MARK: - GraphQLOperationVariableValue -public protocol GraphQLOperationVariableValue { +public protocol GraphQLOperationVariableValue: Sendable { var _jsonEncodableValue: (any JSONEncodable)? { get } } diff --git a/apollo-ios/Sources/ApolloAPI/Optional+asNullable.swift b/apollo-ios/Sources/ApolloAPI/Optional+asNullable.swift index 44df6a702..99b4898cf 100644 --- a/apollo-ios/Sources/ApolloAPI/Optional+asNullable.swift +++ b/apollo-ios/Sources/ApolloAPI/Optional+asNullable.swift @@ -7,7 +7,9 @@ import Foundation public protocol AnyOptional {} @_spi(Internal) -extension Optional: AnyOptional { +extension Optional: AnyOptional { } + +extension Optional where Wrapped: Sendable { #warning("TODO: Document") @_spi(Internal) public var asNullable: GraphQLNullable { diff --git a/apollo-ios/Sources/ApolloWebSocket/DefaultImplementation/WebSocket.swift b/apollo-ios/Sources/ApolloWebSocket/DefaultImplementation/WebSocket.swift index 73487967c..6f180f90f 100644 --- a/apollo-ios/Sources/ApolloWebSocket/DefaultImplementation/WebSocket.swift +++ b/apollo-ios/Sources/ApolloWebSocket/DefaultImplementation/WebSocket.swift @@ -630,7 +630,7 @@ public final class WebSocket: NSObject, WebSocketClient, StreamDelegate, WebSock Finds the HTTP Packet in the TCP stream, by looking for the CRLF. */ private func processHTTP(_ buffer: UnsafePointer, bufferLen: Int) -> Int { - let CRLFBytes = [UInt8(ascii: "\r"), UInt8(ascii: "\n"), UInt8(ascii: "\r"), UInt8(ascii: "\n")] + let CRLFBytes = Data([0x0D, 0x0A]) // "\r\n" var k = 0 var totalSize = 0 for i in 0..