diff --git a/Tests/ApolloInternalTestHelpers/MockNetworkTransport.swift b/Tests/ApolloInternalTestHelpers/MockNetworkTransport.swift index 6f46c6bb9..5d9b8deb2 100644 --- a/Tests/ApolloInternalTestHelpers/MockNetworkTransport.swift +++ b/Tests/ApolloInternalTestHelpers/MockNetworkTransport.swift @@ -19,24 +19,20 @@ public final class MockNetworkTransport: NetworkTransport { public func send( query: Query, cachePolicy: CachePolicy, - context: (any RequestContext)? ) throws -> AsyncThrowingStream, any Error> where Query: GraphQLQuery { try requestChainTransport.send( query: query, - cachePolicy: cachePolicy, - context: context + cachePolicy: cachePolicy ) } public func send( mutation: Mutation, cachePolicy: CachePolicy, - context: (any RequestContext)? ) throws -> AsyncThrowingStream, any Error> where Mutation: GraphQLMutation { try requestChainTransport.send( mutation: mutation, - cachePolicy: cachePolicy, - context: context + cachePolicy: cachePolicy ) } diff --git a/Tests/ApolloInternalTestHelpers/MockOperation.swift b/Tests/ApolloInternalTestHelpers/MockOperation.swift index 7774b3bfe..e83f9ba96 100644 --- a/Tests/ApolloInternalTestHelpers/MockOperation.swift +++ b/Tests/ApolloInternalTestHelpers/MockOperation.swift @@ -15,9 +15,6 @@ open class MockOperation: GraphQLOperation, @unc open var __variables: Variables? public init() {} - - open class var deferredFragments: [DeferredFragmentIdentifier : any ApolloAPI.SelectionSet.Type]? { return nil } - } open class MockQuery: MockOperation, GraphQLQuery, @unchecked Sendable { diff --git a/Tests/ApolloTests/Cache/CacheDependentInterceptorTests.swift b/Tests/ApolloTests/Cache/CacheDependentInterceptorTests.swift index 793b6a8a3..aa6ca0cd5 100644 --- a/Tests/ApolloTests/Cache/CacheDependentInterceptorTests.swift +++ b/Tests/ApolloTests/Cache/CacheDependentInterceptorTests.swift @@ -55,22 +55,31 @@ class CacheDependentInterceptorTests: XCTestCase, CacheDependentTesting, MockRes ]) /// This interceptor will reroute anything that fails with a response code error to retry hitting only the cache - final class RerouteToCacheErrorInterceptor: ApolloErrorInterceptor { + final class RerouteToCacheErrorInterceptor: ApolloInterceptor { nonisolated(unsafe) var handledError: (any Error)? - func intercept( - error: any Error, + func intercept( request: Request, - result: InterceptorResult? - ) async throws -> GraphQLResult where Request: GraphQLRequest { - self.handledError = error - - guard error is ResponseCodeInterceptor.ResponseCodeError else { - throw error + next: NextInterceptorFunction + ) async throws -> InterceptorResultStream> { + + do { + return try await next(request) + + } catch { + self.handledError = error + guard error is ResponseCodeInterceptor.ResponseCodeError else { + throw error + } + var request = request + request.fetchBehavior = FetchBehavior( + shouldAttemptCacheRead: true, + shouldAttemptCacheWrite: true, + shouldAttemptNetworkFetch: .never + ) + + throw RequestChain.Retry(request: request) } - var request = request - request.cachePolicy = .returnCacheDataDontFetch - throw RequestChainRetry(request: request) } } @@ -86,13 +95,8 @@ class CacheDependentInterceptorTests: XCTestCase, CacheDependentTesting, MockRes func interceptors(for operation: Operation) -> [any ApolloInterceptor] where Operation: GraphQLOperation { - [] - } - - func errorInterceptor(for operation: Operation) -> (any ApolloErrorInterceptor)? - where Operation: GraphQLOperation { - self.errorInterceptor - } + [self.errorInterceptor] + } } await CacheDependentInterceptorTests.registerRequestHandler(for: TestURL.mockServer.url) { _ in diff --git a/Tests/ApolloTests/DeferTests.swift b/Tests/ApolloTests/DeferTests.swift index 6445ce208..52e959497 100644 --- a/Tests/ApolloTests/DeferTests.swift +++ b/Tests/ApolloTests/DeferTests.swift @@ -42,7 +42,22 @@ final class DeferTests: XCTestCase, MockResponseProvider { } } - private final class TVShowQuery: MockQuery, @unchecked Sendable { + private struct TVShowQuery: GraphQLQuery, @unchecked Sendable { + static var operationName: String { "TVShowQuery" } + + static var operationDocument: OperationDocument { + .init(definition: .init("Mock Operation Definition")) + } + + static var responseFormat: IncrementalDeferredResponseFormat { + IncrementalDeferredResponseFormat(deferredFragments: [ + .init(label: "deferredGenres", fieldPath: ["show"]): Data.Show.DeferredGenres.self, + .init(label: "deferredFriend", fieldPath: ["show", "characters"]): Data.Show.Character.DeferredFriend.self, + ]) + } + + public var __variables: Variables? + final class Data: MockSelectionSet, @unchecked Sendable { override class var __selections: [Selection] { [ @@ -118,14 +133,6 @@ final class DeferTests: XCTestCase, MockResponseProvider { } } } - - override class var deferredFragments: [DeferredFragmentIdentifier: any SelectionSet.Type]? { - [ - DeferredFragmentIdentifier(label: "deferredGenres", fieldPath: ["show"]): Data.Show.DeferredGenres.self, - DeferredFragmentIdentifier(label: "deferredFriend", fieldPath: ["show", "characters"]): Data.Show.Character - .DeferredFriend.self, - ] - } } let defaultTimeout = 0.5 @@ -185,7 +192,7 @@ final class DeferTests: XCTestCase, MockResponseProvider { let response = results.first let data = response?.data - + expect(data?.__data._fulfilledFragments).to( equal([ ObjectIdentifier(TVShowQuery.Data.self) diff --git a/Tests/ApolloTests/IncrementalGraphQLResponseTests.swift b/Tests/ApolloTests/IncrementalGraphQLResponseTests.swift index 0a7c1d3a1..288829953 100644 --- a/Tests/ApolloTests/IncrementalGraphQLResponseTests.swift +++ b/Tests/ApolloTests/IncrementalGraphQLResponseTests.swift @@ -6,13 +6,24 @@ import Nimble final class IncrementalGraphQLResponseTests: XCTestCase { - class DeferredQuery: MockQuery { - class Data: MockSelectionSet { + class DeferredQuery: GraphQLQuery, @unchecked Sendable { + static var operationName: String { "DeferredQuery" } + + static var operationDocument: ApolloAPI.OperationDocument { .init(definition: .init("Mock Operation Definition")) } + + static var responseFormat: IncrementalDeferredResponseFormat { + IncrementalDeferredResponseFormat(deferredFragments: [ + DeferredFragmentIdentifier(label: "deferredFriend", fieldPath: ["animal"]): Data.Animal.DeferredFriend.self, + // Data.Animal.DeliberatelyMissing is intentionally not here for error testing + ]) + } + + class Data: MockSelectionSet, @unchecked Sendable { override class var __selections: [Selection] {[ .field("animal", Animal.self), ]} - class Animal: AbstractMockSelectionSet { + class Animal: AbstractMockSelectionSet, @unchecked Sendable { override class var __selections: [Selection] {[ .field("__typename", String.self), .field("name", String.self), @@ -33,24 +44,19 @@ final class IncrementalGraphQLResponseTests: XCTestCase { @Deferred var deliberatelyMissing: DeliberatelyMissing? } - class DeferredFriend: MockTypeCase { + class DeferredFriend: MockTypeCase, @unchecked Sendable { override class var __selections: [Selection] {[ .field("friend", String.self), ]} } - class DeliberatelyMissing: MockTypeCase { + class DeliberatelyMissing: MockTypeCase, @unchecked Sendable { override class var __selections: [Selection] {[ .field("key", String.self), ]} } } } - - override class var deferredFragments: [DeferredFragmentIdentifier : any SelectionSet.Type]? {[ - DeferredFragmentIdentifier(label: "deferredFriend", fieldPath: ["animal"]): Data.Animal.DeferredFriend.self, - // Data.Animal.DeliberatelyMissing is intentionally not here for error testing - ]} } // MARK: - Initialization Tests diff --git a/Tests/ApolloTests/Interceptors/JSONResponseParsingInterceptorTests_IncrementalItems.swift b/Tests/ApolloTests/Interceptors/JSONResponseParsingInterceptorTests_IncrementalItems.swift index 2dca064fe..f6f78c1bd 100644 --- a/Tests/ApolloTests/Interceptors/JSONResponseParsingInterceptorTests_IncrementalItems.swift +++ b/Tests/ApolloTests/Interceptors/JSONResponseParsingInterceptorTests_IncrementalItems.swift @@ -431,7 +431,21 @@ final class JSONResponseParsingInterceptorTests_IncrementalItems: XCTestCase { } } - final class AnimalQuery: MockQuery, @unchecked Sendable { + struct AnimalQuery: GraphQLQuery, @unchecked Sendable { + static var operationName: String { "AnimalQuery" } + + static var operationDocument: OperationDocument { + .init(definition: .init("Mock Operation Definition")) + } + + static var responseFormat: IncrementalDeferredResponseFormat { + IncrementalDeferredResponseFormat(deferredFragments: [ + DeferredFragmentIdentifier(label: "deferredGenus", fieldPath: ["animal"]): AnAnimal.Animal.DeferredGenus.self, + DeferredFragmentIdentifier(label: "deferredFriend", fieldPath: ["animal"]): AnAnimal.Animal.DeferredFriend.self, + ]) + } + + typealias Data = AnAnimal final class AnAnimal: MockSelectionSet, @unchecked Sendable { typealias Schema = MockSchemaMetadata @@ -498,12 +512,5 @@ final class JSONResponseParsingInterceptorTests_IncrementalItems: XCTestCase { } } } - - override class var deferredFragments: [DeferredFragmentIdentifier: any SelectionSet.Type]? { - [ - DeferredFragmentIdentifier(label: "deferredGenus", fieldPath: ["animal"]): AnAnimal.Animal.DeferredGenus.self, - DeferredFragmentIdentifier(label: "deferredFriend", fieldPath: ["animal"]): AnAnimal.Animal.DeferredFriend.self, - ] - } } } diff --git a/Tests/ApolloTests/Network/ApolloURLSessionTests.swift b/Tests/ApolloTests/Network/ApolloURLSessionTests.swift index d92730633..2f32fef5d 100644 --- a/Tests/ApolloTests/Network/ApolloURLSessionTests.swift +++ b/Tests/ApolloTests/Network/ApolloURLSessionTests.swift @@ -28,7 +28,6 @@ class ApolloURLSessionTests: XCTestCase, MockResponseProvider { var operation = MockQuery.mock() var additionalHeaders: [String: String] = [:] var cachePolicy: Apollo.CachePolicy = .fetchIgnoringCacheCompletely - var context: (any RequestContext)? = nil var clientAwarenessMetadata: ClientAwarenessMetadata = .none var urlRequest: URLRequest diff --git a/apollo-ios/Sources/Apollo/AnyGraphQLResponse.swift b/apollo-ios/Sources/Apollo/AnyGraphQLResponse.swift deleted file mode 100644 index b41032707..000000000 --- a/apollo-ios/Sources/Apollo/AnyGraphQLResponse.swift +++ /dev/null @@ -1,104 +0,0 @@ -#if !COCOAPODS -@_spi(Internal) import ApolloAPI -#endif - -/// An abstract GraphQL response used for full and incremental responses. -struct AnyGraphQLResponse { - let body: JSONObject - - private let rootKey: CacheReference - private let variables: GraphQLOperation.Variables? - - init( - body: JSONObject, - rootKey: CacheReference, - variables: GraphQLOperation.Variables? - ) { - self.body = try! JSONObject(_jsonValue: body as JSONValue) - 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 = body["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 = body["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.body["errors"] as? [JSONObject] else { - return nil - } - - return errorsEntry.map(GraphQLError.init) - } - - func parseExtensions() -> JSONObject? { - return self.body["extensions"] as? JSONObject - } -} - -// MARK: - Equatable Conformance - -extension AnyGraphQLResponse: Equatable { - static func == (lhs: AnyGraphQLResponse, rhs: AnyGraphQLResponse) -> Bool { - AnySendableHashable.equatableCheck(lhs.body, rhs.body) && - lhs.rootKey == rhs.rootKey && - AnySendableHashable.equatableCheck(lhs.variables?._jsonEncodableObject._jsonValue, rhs.variables?._jsonEncodableObject._jsonValue) - } -} - -// MARK: - Hashable Conformance - -extension AnyGraphQLResponse: Hashable { - func hash(into hasher: inout Hasher) { - hasher.combine(body) - hasher.combine(rootKey) - hasher.combine(variables?._jsonEncodableObject._jsonValue) - } -} diff --git a/apollo-ios/Sources/Apollo/ApolloClient.swift b/apollo-ios/Sources/Apollo/ApolloClient.swift index 4e2446c21..fd5b109d1 100644 --- a/apollo-ios/Sources/Apollo/ApolloClient.swift +++ b/apollo-ios/Sources/Apollo/ApolloClient.swift @@ -1,51 +1,47 @@ -import Foundation import Dispatch +import Foundation + #if !COCOAPODS -import ApolloAPI + import ApolloAPI #endif -/// A cache policy that specifies whether results should be fetched from the server or loaded from the local cache. -#warning("TODO: rethink this API. Onlye valid for queries currently") -public enum CachePolicy: Sendable, Hashable { - /// Return data from the cache if available, else fetch results from the server. - case returnCacheDataElseFetch - /// Always fetch results from the server. - case fetchIgnoringCacheData - /// Always fetch results from the server, and don't store these in the cache. - case fetchIgnoringCacheCompletely - /// Return data from the cache if available, else return an error. - case returnCacheDataDontFetch - /// Return data from the cache if available, and always fetch results from the server. - case returnCacheDataAndFetch +public struct RequestConfiguration: Sendable { + public var requestTimeout: TimeInterval? + public var writeResultsToCache: Bool -#warning("TODO: this unsafe is not properly made atomic. Fix this") - /// The current default cache policy. - nonisolated(unsafe) public static var `default`: CachePolicy = .returnCacheDataElseFetch + public init( + requestTimeout: TimeInterval? = nil, + writeResultsToCache: Bool = true + ) { + self.requestTimeout = requestTimeout + self.writeResultsToCache = writeResultsToCache + } } -/// A handler for operation results. -/// -/// - Parameters: -/// - result: The result of a performed operation. Will have a `GraphQLResult` with any parsed data and any GraphQL errors on `success`, and an `Error` on `failure`. -public typealias GraphQLResultHandler = @Sendable (Result, any Error>) -> Void - +// MARK: - /// The `ApolloClient` class implements the core API for Apollo by conforming to `ApolloClientProtocol`. -public class ApolloClient: ApolloClientProtocol, @unchecked Sendable { +public final class ApolloClient: ApolloClientProtocol, Sendable { let networkTransport: any NetworkTransport public let store: ApolloStore + public let defaultRequestConfiguration: RequestConfiguration + public enum ApolloClientError: Error, LocalizedError, Hashable { + case noResults case noUploadTransport case noSubscriptionTransport public var errorDescription: String? { switch self { + case .noResults: + return "The operation completed without returning any results." case .noUploadTransport: return "Attempting to upload using a transport which does not support uploads. This is a developer error." case .noSubscriptionTransport: - return "Attempting to begin a subscription using a transport which does not support subscriptions. This is a developer error." + return + "Attempting to begin a subscription using a transport which does not support subscriptions. This is a developer error." } } } @@ -56,55 +52,523 @@ public class ApolloClient: ApolloClientProtocol, @unchecked Sendable { /// - networkTransport: A network transport used to send operations to a server. /// - store: A store used as a local cache. Note that if the `NetworkTransport` or any of its dependencies takes /// a store, you should make sure the same store is passed here so that it can be cleared properly. - /// - 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`. + /// - clientAwarenessMetadata: Metadata used by the + /// [client awareness](https://www.apollographql.com/docs/graphos/platform/insights/client-segmentation) + /// feature of GraphOS Studio. public init( networkTransport: any NetworkTransport, - store: ApolloStore + store: ApolloStore, + defaultRequestConfiguration: RequestConfiguration = RequestConfiguration(), + clientAwarenessMetadata: ClientAwarenessMetadata = ClientAwarenessMetadata() ) { self.networkTransport = networkTransport self.store = store + self.defaultRequestConfiguration = defaultRequestConfiguration + self.context = ClientContext(clientAwarenessMetadata: clientAwarenessMetadata) } /// Creates a client with a `RequestChainNetworkTransport` connecting to the specified URL. /// - /// - Parameter url: The URL of a GraphQL server to connect to. + /// - Parameters: + /// - url: The URL of a GraphQL server to connect to. + /// - clientAwarenessMetadata: Metadata used by the + /// [client awareness](https://www.apollographql.com/docs/graphos/platform/insights/client-segmentation) + /// feature of GraphOS Studio. public convenience init( url: URL, + defaultRequestConfiguration: RequestConfiguration = RequestConfiguration(), clientAwarenessMetadata: ClientAwarenessMetadata = ClientAwarenessMetadata() ) { let store = ApolloStore(cache: InMemoryNormalizedCache()) - let provider = DefaultInterceptorProvider(store: store) + let provider = DefaultInterceptorProvider() let transport = RequestChainNetworkTransport( + urlSession: URLSession(configuration: .default), interceptorProvider: provider, - endpointURL: url, - clientAwarenessMetadata: clientAwarenessMetadata + store: store, + endpointURL: url ) self.init( networkTransport: transport, - store: store + store: store, + defaultRequestConfiguration: defaultRequestConfiguration + ) + } + + /// Clears the `NormalizedCache` of the client's `ApolloStore`. + public func clearCache() async throws { + try await self.store.clearCache() + } + + // MARK: - Fetch Query + + // MARK: Fetch Query w/Fetch Behavior + + public func fetch( + query: Query, + fetchBehavior: FetchBehavior = FetchBehavior.CacheElseNetwork, + requestConfiguration: RequestConfiguration? = nil + ) throws -> AsyncThrowingStream, any Error> { + return try doInClientContext { + return try self.networkTransport.send( + query: query, + fetchBehavior: fetchBehavior, + requestConfiguration: requestConfiguration ?? self.defaultRequestConfiguration + ) + } + } + + // MARK: Single Response Format + + public func fetch( + query: Query, + cachePolicy: CachePolicy.Query.SingleResponse, + requestConfiguration: RequestConfiguration? = nil + ) async throws -> GraphQLResult + where Query.ResponseFormat == SingleResponseFormat { + for try await result in try fetch( + query: query, + fetchBehavior: cachePolicy.toFetchBehavior(), + requestConfiguration: requestConfiguration + ) { + return result + } + throw ApolloClientError.noResults + } + + public func fetch( + query: Query, + cachePolicy: CachePolicy.Query.CacheThenNetwork, + requestConfiguration: RequestConfiguration? = nil + ) throws -> AsyncThrowingStream, any Error> + where Query.ResponseFormat == SingleResponseFormat { + return try fetch( + query: query, + fetchBehavior: FetchBehavior.CacheThenNetwork, + requestConfiguration: requestConfiguration + ) + } + + // MARK: Incremental Response Format + + public func fetch( + query: Query, + cachePolicy: CachePolicy.Query.SingleResponse, + requestConfiguration: RequestConfiguration? = nil + ) throws -> AsyncThrowingStream, any Error> + where Query.ResponseFormat == IncrementalDeferredResponseFormat { + return try fetch( + query: query, + fetchBehavior: cachePolicy.toFetchBehavior(), + requestConfiguration: requestConfiguration + ) + } + + public func fetch( + query: Query, + cachePolicy: CachePolicy.Query.CacheThenNetwork, + requestConfiguration: RequestConfiguration? = nil + ) throws -> AsyncThrowingStream, any Error> + where Query.ResponseFormat == IncrementalDeferredResponseFormat { + return try fetch( + query: query, + fetchBehavior: cachePolicy.toFetchBehavior(), + requestConfiguration: requestConfiguration ) } - public func clearCache(callbackQueue: DispatchQueue = .main, - completion: (@Sendable (Result) -> Void)? = nil) { + // MARK: Cache Only + + public func fetch( + query: Query, + cachePolicy: CachePolicy.Query.CacheOnly, + requestConfiguration: RequestConfiguration? = nil + ) async throws -> GraphQLResult { + for try await result in try fetch( + query: query, + fetchBehavior: cachePolicy.toFetchBehavior(), + requestConfiguration: requestConfiguration + ) { + return result + } + throw ApolloClientError.noResults + } + + // MARK: - Watch Query + + // MARK: Watch Query w/Fetch Behavior + + /// Watches a query by first fetching an initial result from the server or from the local cache, depending on the + /// current contents of the cache and the specified cache policy. After the initial fetch, the returned query + /// watcher object will get notified whenever any of the data the query result depends on changes in the local cache, + /// and calls the result handler again with the new result. + /// + /// - Parameters: + /// - query: The query to fetch. + /// - fetchBehavior: A ``FetchBehavior`` that specifies when results should be fetched from the server or from the + /// local cache. + /// - requestConfiguration: A ``RequestConfiguration`` to use for the watcher's initial fetch. If `nil` the + /// client's `defaultRequestConfiguration` will be used. + /// - refetchOnFailedUpdates: Should the watcher perform a network fetch when it's watched + /// objects have changed, but reloading them from the cache fails. Defaults to `true`. + /// - resultHandler: A closure that is called when query results are available or when an error occurs. + /// - Returns: A query watcher object that can be used to control the watching behavior. + public func watch( + query: Query, + fetchBehavior: FetchBehavior = FetchBehavior.CacheElseNetwork, + requestConfiguration: RequestConfiguration? = nil, + refetchOnFailedUpdates: Bool = true, + resultHandler: @escaping GraphQLQueryWatcher.ResultHandler + ) -> GraphQLQueryWatcher { + let watcher = GraphQLQueryWatcher( + client: self, + query: query, + refetchOnFailedUpdates: refetchOnFailedUpdates, + resultHandler: resultHandler + ) + Task { + await watcher.fetch(fetchBehavior: fetchBehavior, requestConfiguration: requestConfiguration) + } + return watcher + } + + // MARK: Watch Query - CachePolicy Overloads + + /// Watches a query by first fetching an initial result from the server or from the local cache, depending on the + /// current contents of the cache and the specified cache policy. After the initial fetch, the returned query + /// watcher object will get notified whenever any of the data the query result depends on changes in the local cache, + /// and calls the result handler again with the new result. + /// + /// - Parameters: + /// - query: The query to fetch. + /// - cachePolicy: A cache policy that specifies when results should be fetched from the server or from the + /// local cache. + /// - requestConfiguration: A ``RequestConfiguration`` to use for the watcher's initial fetch. If `nil` the + /// client's `defaultRequestConfiguration` will be used. + /// - refetchOnFailedUpdates: Should the watcher perform a network fetch when it's watched + /// objects have changed, but reloading them from the cache fails. Defaults to `true`. + /// - resultHandler: A closure that is called when query results are available or when an error occurs. + /// - Returns: A query watcher object that can be used to control the watching behavior. + public func watch( + query: Query, + cachePolicy: CachePolicy.Query.SingleResponse, + requestConfiguration: RequestConfiguration? = nil, + refetchOnFailedUpdates: Bool = true, + resultHandler: @escaping GraphQLQueryWatcher.ResultHandler + ) -> GraphQLQueryWatcher { + return self.watch( + query: query, + fetchBehavior: cachePolicy.toFetchBehavior(), + requestConfiguration: requestConfiguration, + refetchOnFailedUpdates: refetchOnFailedUpdates, + resultHandler: resultHandler + ) + } + + /// Watches a query by first fetching an initial result from the server or from the local cache, depending on the + /// current contents of the cache and the specified cache policy. After the initial fetch, the returned query + /// watcher object will get notified whenever any of the data the query result depends on changes in the local cache, + /// and calls the result handler again with the new result. + /// + /// - Parameters: + /// - query: The query to fetch. + /// - cachePolicy: A cache policy that specifies when results should be fetched from the server or from the + /// local cache. + /// - requestConfiguration: A ``RequestConfiguration`` to use for the watcher's initial fetch. If `nil` the + /// client's `defaultRequestConfiguration` will be used. + /// - refetchOnFailedUpdates: Should the watcher perform a network fetch when it's watched + /// objects have changed, but reloading them from the cache fails. Defaults to `true`. + /// - resultHandler: A closure that is called when query results are available or when an error occurs. + /// - Returns: A query watcher object that can be used to control the watching behavior. + public func watch( + query: Query, + cachePolicy: CachePolicy.Query.CacheThenNetwork, + requestConfiguration: RequestConfiguration? = nil, + refetchOnFailedUpdates: Bool = true, + resultHandler: @escaping GraphQLQueryWatcher.ResultHandler + ) -> GraphQLQueryWatcher { + return self.watch( + query: query, + fetchBehavior: cachePolicy.toFetchBehavior(), + requestConfiguration: requestConfiguration, + refetchOnFailedUpdates: refetchOnFailedUpdates, + resultHandler: resultHandler + ) + } + + /// Watches a query by first fetching an initial result from the server or from the local cache, depending on the + /// current contents of the cache and the specified cache policy. After the initial fetch, the returned query + /// watcher object will get notified whenever any of the data the query result depends on changes in the local cache, + /// and calls the result handler again with the new result. + /// + /// - Parameters: + /// - query: The query to fetch. + /// - cachePolicy: A cache policy that specifies when results should be fetched from the server or from the + /// local cache. + /// - requestConfiguration: A ``RequestConfiguration`` to use for the watcher's initial fetch. If `nil` the + /// client's `defaultRequestConfiguration` will be used. + /// - refetchOnFailedUpdates: Should the watcher perform a network fetch when it's watched + /// objects have changed, but reloading them from the cache fails. Defaults to `true`. + /// - resultHandler: A closure that is called when query results are available or when an error occurs. + /// - Returns: A query watcher object that can be used to control the watching behavior. + public func watch( + query: Query, + cachePolicy: CachePolicy.Query.CacheOnly, + requestConfiguration: RequestConfiguration? = nil, + refetchOnFailedUpdates: Bool = true, + resultHandler: @escaping GraphQLQueryWatcher.ResultHandler + ) -> GraphQLQueryWatcher { + return self.watch( + query: query, + fetchBehavior: cachePolicy.toFetchBehavior(), + requestConfiguration: requestConfiguration, + refetchOnFailedUpdates: refetchOnFailedUpdates, + resultHandler: resultHandler + ) + } + + // MARK: - Perform Mutation + + /// Performs a mutation by sending it to the server. + /// + /// Mutations always need to send their mutation data to the server, so there is no `cachePolicy` or `fetchBehavior` + /// parameter. + /// + /// - Parameters: + /// - mutation: The mutation to perform. + /// - requestConfiguration: A ``RequestConfiguration`` to use for the watcher's initial fetch. If `nil` the + /// client's `defaultRequestConfiguration` will be used. + public func perform( + mutation: Mutation, + requestConfiguration: RequestConfiguration? = nil + ) async throws -> GraphQLResult + where Mutation.ResponseFormat == SingleResponseFormat { + for try await result in try self.sendMutation( + mutation: mutation, + requestConfiguration: requestConfiguration + ) { + return result + } + throw ApolloClientError.noResults + } + + /// Performs a mutation by sending it to the server. + /// + /// Mutations always need to send their mutation data to the server, so there is no `cachePolicy` or `fetchBehavior` + /// parameter. + /// + /// - Parameters: + /// - mutation: The mutation to perform. + /// - requestConfiguration: A ``RequestConfiguration`` to use for the watcher's initial fetch. If `nil` the + /// client's `defaultRequestConfiguration` will be used. + public func perform( + mutation: Mutation, + requestConfiguration: RequestConfiguration? = nil + ) throws -> AsyncThrowingStream, any Error> + where Mutation.ResponseFormat == IncrementalDeferredResponseFormat { + return try sendMutation(mutation: mutation, requestConfiguration: requestConfiguration) + } + + public func sendMutation( + mutation: Mutation, + requestConfiguration: RequestConfiguration? + ) throws -> AsyncThrowingStream, any Error> { + return try doInClientContext { + return try self.networkTransport.send( + mutation: mutation, + requestConfiguration: requestConfiguration ?? defaultRequestConfiguration + ) + } + } + + // MARK: - Upload Operation + + /// Uploads the given files with the given operation. + /// + /// - Parameters: + /// - operation: The operation to send + /// - files: An array of `GraphQLFile` objects to send. + /// - requestConfiguration: A ``RequestConfiguration`` to use for the watcher's initial fetch. If `nil` the + /// client's `defaultRequestConfiguration` will be used. + /// + /// - Note: An error will be thrown If your `networkTransport` does not also conform to `UploadingNetworkTransport`. + public func upload( + operation: Operation, + files: [GraphQLFile], + requestConfiguration: RequestConfiguration? = nil + ) async throws -> GraphQLResult + where Operation.ResponseFormat == SingleResponseFormat { + for try await result in try self.sendUpload( + operation: operation, + files: files, + requestConfiguration: requestConfiguration + ) { + return result + } + throw ApolloClientError.noResults + } + + /// Uploads the given files with the given operation. + /// + /// - Parameters: + /// - operation: The operation to send + /// - files: An array of `GraphQLFile` objects to send. + /// - requestConfiguration: A ``RequestConfiguration`` to use for the watcher's initial fetch. If `nil` the + /// client's `defaultRequestConfiguration` will be used. + /// + /// - Note: An error will be thrown If your `networkTransport` does not also conform to `UploadingNetworkTransport`. + public func upload( + operation: Operation, + files: [GraphQLFile], + requestConfiguration: RequestConfiguration? = nil + ) throws -> AsyncThrowingStream, any Error> + where Operation.ResponseFormat == IncrementalDeferredResponseFormat { + return try self.sendUpload( + operation: operation, + files: files, + requestConfiguration: requestConfiguration + ) + } + + private func sendUpload( + operation: Operation, + files: [GraphQLFile], + requestConfiguration: RequestConfiguration? + ) throws -> AsyncThrowingStream, any Error> { + guard let uploadingTransport = self.networkTransport as? (any UploadingNetworkTransport) else { + assertionFailure( + "Trying to upload without an uploading transport. Please make sure your network transport conforms to `UploadingNetworkTransport`." + ) + throw ApolloClientError.noUploadTransport + } + + return try doInClientContext { + return try uploadingTransport.upload( + operation: operation, + files: files, + requestConfiguration: requestConfiguration ?? defaultRequestConfiguration + ) + } + } + + // MARK: - Subscription Operations + + /// Subscribe to a subscription + /// + /// - Parameters: + /// - subscription: The subscription to subscribe to. + /// - cachePolicy: A cache policy that specifies when results should be fetched from the server or from the + /// local cache. + public func subscribe( + subscription: Subscription, + cachePolicy: CachePolicy.Subscription = .cacheThenNetwork, + requestConfiguration: RequestConfiguration? = nil + ) async throws -> AsyncThrowingStream, any Error> { + guard let subscriptionTransport = self.networkTransport as? (any SubscriptionNetworkTransport) else { + assertionFailure( + "Trying to subscribe without a subscription transport. Please make sure your network transport conforms to `SubscriptionNetworkTransport`." + ) + throw ApolloClientError.noSubscriptionTransport + } + + return try doInClientContext { + return try subscriptionTransport + .send( + subscription: subscription, + fetchBehavior: cachePolicy.toFetchBehavior(), + requestConfiguration: requestConfiguration ?? defaultRequestConfiguration + ) + } + } + + // MARK: - ClientContext + + @TaskLocal internal static var context: ClientContext? + + private let context: ClientContext + + struct ClientContext: Sendable { + /// The telemetry metadata about the client. This is used by GraphOS Studio's + /// [client awareness](https://www.apollographql.com/docs/graphos/platform/insights/client-segmentation) + /// feature. + let clientAwarenessMetadata: ClientAwarenessMetadata + } + + private func doInClientContext(_ block: () throws -> T) rethrows -> T { + return try ApolloClient.$context.withValue(self.context) { + return try block() + } + } +} + +// MARK: - Deprecations + +/// A handler for operation results. +/// +/// - Parameters: +/// - result: The result of a performed operation. Will have a `GraphQLResult` with any parsed data and any GraphQL errors on `success`, and an `Error` on `failure`. +@available(*, deprecated) +public typealias GraphQLResultHandler = @Sendable (Result, any Error>) -> + Void + +@available(*, deprecated) +public enum CachePolicy_v1: Sendable, Hashable { + /// Return data from the cache if available, else fetch results from the server. + case returnCacheDataElseFetch + /// Always fetch results from the server. + case fetchIgnoringCacheData + /// Always fetch results from the server, and don't store these in the cache. + case fetchIgnoringCacheCompletely + /// Return data from the cache if available, else return an error. + case returnCacheDataDontFetch + /// Return data from the cache if available, and always fetch results from the server. + case returnCacheDataAndFetch + + /// The current default cache policy. + nonisolated(unsafe) public static var `default`: CachePolicy_v1 = .returnCacheDataElseFetch + + func toFetchBehavior() -> FetchBehavior { + switch self { + case .returnCacheDataElseFetch: + return FetchBehavior.CacheElseNetwork + case .fetchIgnoringCacheData: + return FetchBehavior.NetworkOnly + case .fetchIgnoringCacheCompletely: + return FetchBehavior.NetworkOnly + case .returnCacheDataDontFetch: + return FetchBehavior.CacheOnly + case .returnCacheDataAndFetch: + return FetchBehavior.CacheThenNetwork + } + } +} + +extension ApolloClient { + + @available(*, deprecated) + public func clearCache( + callbackQueue: DispatchQueue = .main, + completion: (@Sendable (Result) -> Void)? = nil + ) { self.store.clearCache(callbackQueue: callbackQueue, completion: completion) } + @_disfavoredOverload + @available(*, deprecated) @discardableResult public func fetch( query: Query, - cachePolicy: CachePolicy = .default, + cachePolicy: CachePolicy_v1? = nil, context: (any RequestContext)? = nil, queue: DispatchQueue = .main, resultHandler: GraphQLResultHandler? = nil - ) -> (any Cancellable) { + ) -> (any Cancellable) { + let cachePolicy = cachePolicy ?? CachePolicy_v1.default return awaitStreamInTask( { - try self.networkTransport.send( + try self.fetch( query: query, - cachePolicy: cachePolicy, - context: context + fetchBehavior: cachePolicy.toFetchBehavior(), + requestConfiguration: RequestConfiguration(writeResultsToCache: cachePolicy != .fetchIgnoringCacheCompletely) ) }, callbackQueue: queue, @@ -141,49 +605,56 @@ public class ApolloClient: ApolloClientProtocol, @unchecked Sendable { return TaskCancellable(task: task) } - /// Watches a query by first fetching an initial result from the server or from the local cache, depending on the current contents of the cache and the specified cache policy. After the initial fetch, the returned query watcher object will get notified whenever any of the data the query result depends on changes in the local cache, and calls the result handler again with the new result. - /// - /// - Parameters: - /// - query: The query to fetch. - /// - cachePolicy: A cache policy that specifies when results should be fetched from the server or from the local cache. - /// - refetchOnFailedUpdates: Should the watcher perform a network fetch when it's watched - /// objects have changed, but reloading them from the cache fails. Should default to `true`. - /// - context: [optional] A context that is being passed through the request chain. Should default to `nil`. - /// - callbackQueue: A dispatch queue on which the result handler will be called. Should default to the main queue. - /// - resultHandler: [optional] A closure that is called when query results are available or when an error occurs. - /// - Returns: A query watcher object that can be used to control the watching behavior. + @_disfavoredOverload + @available( + *, + deprecated, + renamed: "watch(query:cachePolicy:refetchOnFailedUpdates:context:callbackQueue:resultHandler:)" + ) public func watch( query: Query, - cachePolicy: CachePolicy = .default, - refetchOnFailedUpdates: Bool = true, + cachePolicy: CachePolicy_v1? = nil, context: (any RequestContext)? = nil, callbackQueue: DispatchQueue = .main, resultHandler: @escaping GraphQLResultHandler ) -> GraphQLQueryWatcher { - let watcher = GraphQLQueryWatcher(client: self, - query: query, - refetchOnFailedUpdates: refetchOnFailedUpdates, - context: context, - callbackQueue: callbackQueue, - resultHandler: resultHandler) - watcher.fetch(cachePolicy: cachePolicy) - return watcher + let cachePolicy = cachePolicy ?? CachePolicy_v1.default + let config = RequestConfiguration( + requestTimeout: defaultRequestConfiguration.requestTimeout, + writeResultsToCache: cachePolicy == .fetchIgnoringCacheCompletely + ? false : defaultRequestConfiguration.writeResultsToCache + ) + return self.watch( + query: query, + fetchBehavior: cachePolicy.toFetchBehavior(), + requestConfiguration: config, + resultHandler: resultHandler + ) } @discardableResult + @_disfavoredOverload + @available( + *, + deprecated, + renamed: "perform(mutation:requestConfiguration:)" + ) public func perform( mutation: Mutation, publishResultToStore: Bool = true, - context: (any RequestContext)? = nil, queue: DispatchQueue = .main, resultHandler: GraphQLResultHandler? = nil ) -> (any Cancellable) { + let config = RequestConfiguration( + requestTimeout: defaultRequestConfiguration.requestTimeout, + writeResultsToCache: publishResultToStore + ) + return awaitStreamInTask( { try self.networkTransport.send( mutation: mutation, - cachePolicy: publishResultToStore ? .default : .fetchIgnoringCacheCompletely, - context: context + requestConfiguration: config ) }, callbackQueue: queue, @@ -192,27 +663,24 @@ public class ApolloClient: ApolloClientProtocol, @unchecked Sendable { } @discardableResult + @_disfavoredOverload + @available( + *, + deprecated, + renamed: "upload(operation:files:requestConfiguration:)" + ) public func upload( operation: Operation, files: [GraphQLFile], - context: (any RequestContext)? = nil, queue: DispatchQueue = .main, resultHandler: GraphQLResultHandler? = nil ) -> (any Cancellable) { - guard let uploadingTransport = self.networkTransport as? (any UploadingNetworkTransport) else { - assertionFailure("Trying to upload without an uploading transport. Please make sure your network transport conforms to `UploadingNetworkTransport`.") - queue.async { - resultHandler?(.failure(ApolloClientError.noUploadTransport)) - } - return EmptyCancellable() - } - return awaitStreamInTask( { - try uploadingTransport.upload( + try self.sendUpload( operation: operation, files: files, - context: context + requestConfiguration: nil ) }, callbackQueue: queue, @@ -220,54 +688,25 @@ public class ApolloClient: ApolloClientProtocol, @unchecked Sendable { ) } + @discardableResult + @_disfavoredOverload + @available( + *, + deprecated, + renamed: "subscribe(subscription:cachePolicy:requestConfiguration:)" + ) public func subscribe( subscription: Subscription, - context: (any RequestContext)? = nil, queue: DispatchQueue = .main, resultHandler: @escaping GraphQLResultHandler ) -> any Cancellable { - guard let networkTransport = networkTransport as? (any SubscriptionNetworkTransport) else { - assertionFailure("Trying to subscribe without a subscription transport. Please make sure your network transport conforms to `SubscriptionNetworkTransport`.") - queue.async { - resultHandler(.failure(ApolloClientError.noSubscriptionTransport)) - } - return EmptyCancellable() - } - return awaitStreamInTask( { - try networkTransport.send( - subscription: subscription, - cachePolicy: .default, - context: context) + try await self.subscribe(subscription: subscription) }, callbackQueue: queue, completion: resultHandler - ) - } -} - -// MARK: - Deprecations - -extension ApolloClient { - - @_disfavoredOverload - @available(*, deprecated, - renamed: "watch(query:cachePolicy:refetchOnFailedUpdates:context:callbackQueue:resultHandler:)") - public func watch( - query: Query, - cachePolicy: CachePolicy = .default, - context: (any RequestContext)? = nil, - callbackQueue: DispatchQueue = .main, - resultHandler: @escaping GraphQLResultHandler - ) -> GraphQLQueryWatcher { - let watcher = GraphQLQueryWatcher(client: self, - query: query, - context: context, - callbackQueue: callbackQueue, - resultHandler: resultHandler) - watcher.fetch(cachePolicy: cachePolicy) - return watcher + ) } } diff --git a/apollo-ios/Sources/Apollo/ApolloClientProtocol.swift b/apollo-ios/Sources/Apollo/ApolloClientProtocol.swift index f9e643a62..5b92f2494 100644 --- a/apollo-ios/Sources/Apollo/ApolloClientProtocol.swift +++ b/apollo-ios/Sources/Apollo/ApolloClientProtocol.swift @@ -4,90 +4,160 @@ import ApolloAPI #endif /// The `ApolloClientProtocol` provides the core API for Apollo. This API provides methods to fetch and watch queries, and to perform mutations. -public protocol ApolloClientProtocol: AnyObject { +#warning("TODO: move this to ApolloTestSupport? In test support, should have extension that implements all cache policy type functions from fetch behavior function") +public protocol ApolloClientProtocol: AnyObject, Sendable { /// A store used as a local cache. var store: ApolloStore { get } - /// Clears the underlying cache. - /// Be aware: In more complex setups, the same underlying cache can be used across multiple instances, so if you call this on one instance, it'll clear that cache across all instances which share that cache. - /// - /// - Parameters: - /// - callbackQueue: The queue to fall back on. Should default to the main queue. - /// - completion: [optional] A completion closure to execute when clearing has completed. Should default to nil. - func clearCache(callbackQueue: DispatchQueue, completion: (@Sendable (Result) -> Void)?) + /// Clears the `NormalizedCache` of the client's `ApolloStore`. + func clearCache() async throws + + // MARK: - Fetch Functions - /// Fetches a query from the server or from the local cache, depending on the current contents of the cache and the specified cache policy. + /// Fetches a query from the server or from the local cache, depending on the current contents of the cache and the + /// specified cache policy. /// /// - Parameters: /// - query: The query to fetch. - /// - cachePolicy: A cache policy that specifies when results should be fetched from the server and when data should be loaded from the local cache. - /// - queue: A dispatch queue on which the result handler will be called. Should default to the main queue. - /// - context: [optional] A context that is being passed through the request chain. Should default to `nil`. - /// - resultHandler: [optional] A closure that is called when query results are available or when an error occurs. - /// - Returns: An object that can be used to cancel an in progress fetch. - func fetch(query: Query, - cachePolicy: CachePolicy, - context: (any RequestContext)?, - queue: DispatchQueue, - resultHandler: GraphQLResultHandler?) -> (any Cancellable) - - /// Watches a query by first fetching an initial result from the server or from the local cache, depending on the current contents of the cache and the specified cache policy. After the initial fetch, the returned query watcher object will get notified whenever any of the data the query result depends on changes in the local cache, and calls the result handler again with the new result. + /// - fetchBehavior: A ``FetchBehavior`` that specifies when results should be fetched from the server or from the + /// local cache. + /// - requestConfiguration: A configuration used to configure per-request behaviors for this request + func fetch( + query: Query, + fetchBehavior: FetchBehavior, + requestConfiguration: RequestConfiguration? + ) throws -> AsyncThrowingStream, any Error> + + func fetch( + query: Query, + cachePolicy: CachePolicy.Query.CacheThenNetwork, + requestConfiguration: RequestConfiguration? + ) throws -> AsyncThrowingStream, any Error> + where Query.ResponseFormat == SingleResponseFormat + + func fetch( + query: Query, + cachePolicy: CachePolicy.Query.SingleResponse, + requestConfiguration: RequestConfiguration? + ) throws -> AsyncThrowingStream, any Error> + where Query.ResponseFormat == IncrementalDeferredResponseFormat + + func fetch( + query: Query, + cachePolicy: CachePolicy.Query.CacheThenNetwork, + requestConfiguration: RequestConfiguration? + ) throws -> AsyncThrowingStream, any Error> + where Query.ResponseFormat == IncrementalDeferredResponseFormat + + func fetch( + query: Query, + cachePolicy: CachePolicy.Query.CacheOnly, + requestConfiguration: RequestConfiguration? + ) async throws -> GraphQLResult + + // MARK: - Watch Functions + + /// Watches a query by first fetching an initial result from the server or from the local cache, depending on the + /// current contents of the cache and the specified cache policy. After the initial fetch, the returned query + /// watcher object will get notified whenever any of the data the query result depends on changes in the local cache, + /// and calls the result handler again with the new result. /// /// - Parameters: /// - query: The query to fetch. - /// - cachePolicy: A cache policy that specifies when results should be fetched from the server or from the local cache. - /// - context: [optional] A context that is being passed through the request chain. Should default to `nil`. - /// - callbackQueue: A dispatch queue on which the result handler will be called. Should default to the main queue. - /// - resultHandler: [optional] A closure that is called when query results are available or when an error occurs. + /// - fetchBehavior: A ``FetchBehavior`` that specifies when results should be fetched from the server or from the + /// local cache. + /// - requestConfiguration: A ``RequestConfiguration`` to use for the watcher's initial fetch. If `nil` the + /// client's `defaultRequestConfiguration` will be used. + /// - refetchOnFailedUpdates: Should the watcher perform a network fetch when it's watched + /// objects have changed, but reloading them from the cache fails. Defaults to `true`. + /// - resultHandler: A closure that is called when query results are available or when an error occurs. /// - Returns: A query watcher object that can be used to control the watching behavior. - func watch(query: Query, - cachePolicy: CachePolicy, - context: (any RequestContext)?, - callbackQueue: DispatchQueue, - resultHandler: @escaping GraphQLResultHandler) -> GraphQLQueryWatcher + func watch( + query: Query, + fetchBehavior: FetchBehavior, + requestConfiguration: RequestConfiguration?, + refetchOnFailedUpdates: Bool, + resultHandler: @escaping GraphQLQueryWatcher.ResultHandler + ) -> GraphQLQueryWatcher + + func watch( + query: Query, + cachePolicy: CachePolicy.Query.SingleResponse, + requestConfiguration: RequestConfiguration?, + refetchOnFailedUpdates: Bool, + resultHandler: @escaping GraphQLQueryWatcher.ResultHandler + ) -> GraphQLQueryWatcher + + func watch( + query: Query, + cachePolicy: CachePolicy.Query.CacheThenNetwork, + requestConfiguration: RequestConfiguration?, + refetchOnFailedUpdates: Bool, + resultHandler: @escaping GraphQLQueryWatcher.ResultHandler + ) -> GraphQLQueryWatcher + + func watch( + query: Query, + cachePolicy: CachePolicy.Query.CacheOnly, + requestConfiguration: RequestConfiguration?, + refetchOnFailedUpdates: Bool, + resultHandler: @escaping GraphQLQueryWatcher.ResultHandler + ) -> GraphQLQueryWatcher + + // MARK: - Mutation Functions /// Performs a mutation by sending it to the server. /// + /// Mutations always need to send their mutation data to the server, so there is no `cachePolicy` or `fetchBehavior` + /// parameter. + /// /// - Parameters: /// - mutation: The mutation to perform. - /// - publishResultToStore: If `true`, this will publish the result returned from the operation to the cache store. Default is `true`. - /// - context: [optional] A context that is being passed through the request chain. Should default to `nil`. - /// - queue: A dispatch queue on which the result handler will be called. Should default to the main queue. - /// - resultHandler: An optional closure that is called when mutation results are available or when an error occurs. - /// - Returns: An object that can be used to cancel an in progress mutation. - func perform(mutation: Mutation, - publishResultToStore: Bool, - context: (any RequestContext)?, - queue: DispatchQueue, - resultHandler: GraphQLResultHandler?) -> (any Cancellable) + /// - requestConfiguration: A ``RequestConfiguration`` to use for the watcher's initial fetch. If `nil` the + /// client's `defaultRequestConfiguration` will be used. + func perform( + mutation: Mutation, + requestConfiguration: RequestConfiguration? + ) async throws -> GraphQLResult + where Mutation.ResponseFormat == SingleResponseFormat + + func perform( + mutation: Mutation, + requestConfiguration: RequestConfiguration? + ) throws -> AsyncThrowingStream, any Error> + where Mutation.ResponseFormat == IncrementalDeferredResponseFormat + + // MARK: - Upload Functions /// Uploads the given files with the given operation. /// /// - Parameters: /// - 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. Should default to `nil`. - /// - queue: A dispatch queue on which the result handler will be called. Should default to the main queue. - /// - completionHandler: The completion handler to execute when the request completes or errors. Note that an error will be returned If your `networkTransport` does not also conform to `UploadingNetworkTransport`. - /// - Returns: An object that can be used to cancel an in progress request. - func upload(operation: Operation, - files: [GraphQLFile], - context: (any RequestContext)?, - queue: DispatchQueue, - resultHandler: GraphQLResultHandler?) -> (any Cancellable) + /// - requestConfiguration: A ``RequestConfiguration`` to use for the watcher's initial fetch. If `nil` the + /// client's `defaultRequestConfiguration` will be used. + /// + /// - Note: An error will be thrown If your `networkTransport` does not also conform to `UploadingNetworkTransport`. + func upload( + operation: Operation, + files: [GraphQLFile], + requestConfiguration: RequestConfiguration? + ) async throws -> GraphQLResult + where Operation.ResponseFormat == SingleResponseFormat + + // MARK: - Subscription Functions /// Subscribe to a subscription /// /// - Parameters: /// - subscription: The subscription to subscribe to. - /// - context: [optional] A context that is being passed through the request chain. Should default to `nil`. - /// - fetchHTTPMethod: The HTTP Method to be used. - /// - queue: A dispatch queue on which the result handler will be called. Should default to the main queue. - /// - resultHandler: An optional closure that is called when mutation results are available or when an error occurs. - /// - Returns: An object that can be used to cancel an in progress subscription. - func subscribe(subscription: Subscription, - context: (any RequestContext)?, - queue: DispatchQueue, - resultHandler: @escaping GraphQLResultHandler) -> any Cancellable + /// - cachePolicy: A cache policy that specifies when results should be fetched from the server or from the + /// local cache. + func subscribe( + subscription: Subscription, + cachePolicy: CachePolicy.Subscription, + requestConfiguration: RequestConfiguration? + ) async throws -> AsyncThrowingStream, any Error> + } diff --git a/apollo-ios/Sources/Apollo/ApolloStore.swift b/apollo-ios/Sources/Apollo/ApolloStore.swift index fd3eba9ed..24fca19f4 100644 --- a/apollo-ios/Sources/Apollo/ApolloStore.swift +++ b/apollo-ios/Sources/Apollo/ApolloStore.swift @@ -20,8 +20,6 @@ public protocol ApolloStoreSubscriber: AnyObject, Sendable { /// The `ApolloStore` class acts as a local cache for normalized GraphQL results. #warning("TODO: Docs. ReaderWriter usage; why you should not share a cache with 2 stores, etc.") public final class ApolloStore: Sendable { - #warning("TODO: remove queue") - private let queue: DispatchQueue private let readerWriterLock = ReaderWriter() /// The `NormalizedCache` itself is not thread-safe. Access to the cache by a single store is made @@ -33,7 +31,7 @@ public final class ApolloStore: Sendable { /// In order to comply with `Sendable` requirements, this unsafe property should /// only be accessed within a `readerWriterLock.write { }` block. - nonisolated(unsafe) private(set) var subscribers: [any ApolloStoreSubscriber] = [] + nonisolated(unsafe) private(set) var subscribers: [SubscriptionToken: any ApolloStoreSubscriber] = [:] /// Designated initializer /// - Parameters: @@ -41,11 +39,10 @@ public final class ApolloStore: Sendable { /// Defaults to an `InMemoryNormalizedCache`. public init(cache: any NormalizedCache = InMemoryNormalizedCache()) { self.cache = cache - self.queue = DispatchQueue(label: "com.apollographql.ApolloStore", attributes: .concurrent) } fileprivate func didChangeKeys(_ changedKeys: Set) { - for subscriber in self.subscribers { + for subscriber in self.subscribers.values { subscriber.store(self, didChangeKeys: changedKeys) } } @@ -72,23 +69,25 @@ public final class ApolloStore: Sendable { /// - Parameters: /// - subscriber: A subscriber to receive content change notificatons. To avoid a retain cycle, /// ensure you call `unsubscribe` on this subscriber before it goes out of scope. - public func subscribe(_ subscriber: any ApolloStoreSubscriber) { - Task { + public func subscribe(_ subscriber: any ApolloStoreSubscriber) -> SubscriptionToken { + let token = SubscriptionToken(id: ObjectIdentifier(subscriber)) + Task(priority: Task.currentPriority < .medium ? .medium : Task.currentPriority) { await readerWriterLock.write { - self.subscribers.append(subscriber) + self.subscribers[token] = subscriber } } + return token } /// Unsubscribes from notifications of ApolloStore content changes /// /// - Parameters: - /// - subscriber: A subscribe that has previously been added via `subscribe`. To avoid retain cycles, - /// call `unsubscribe` on all active subscribers before they go out of scope. - public func unsubscribe(_ subscriber: any ApolloStoreSubscriber) { - Task { + /// - subscriptionToken: An opaque token for the subscriber that was provided via `subscribe(_:)`. + /// To avoid retain cycles, call `unsubscribe` on all active subscribers before they go out of scope. + public func unsubscribe(_ subscriptionToken: SubscriptionToken) { + Task(priority: Task.currentPriority > .medium ? .medium : Task.currentPriority) { await readerWriterLock.write { - self.subscribers = self.subscribers.filter({ $0 !== subscriber }) + self.subscribers.removeValue(forKey: subscriptionToken) } } } @@ -121,6 +120,7 @@ public final class ApolloStore: Sendable { return value } + #warning("TODO: Make cache reads return nil instead of throwing on cache miss.") /// Loads the results for the given operation from the cache. /// /// This function will throw an error on a cache miss. @@ -149,6 +149,11 @@ public final class ApolloStore: Sendable { } } + // MARK: - + public struct SubscriptionToken: Sendable, Hashable { + let id: ObjectIdentifier + } + // MARK: - public enum Error: Swift.Error { case notWithinReadTransaction diff --git a/apollo-ios/Sources/Apollo/ApolloURLSession.swift b/apollo-ios/Sources/Apollo/ApolloURLSession.swift index 83f054434..5177605a0 100644 --- a/apollo-ios/Sources/Apollo/ApolloURLSession.swift +++ b/apollo-ios/Sources/Apollo/ApolloURLSession.swift @@ -1,15 +1,15 @@ import Foundation public protocol ApolloURLSession: Sendable { - func chunks(for request: some GraphQLRequest) async throws -> (any AsyncChunkSequence, URLResponse) + func chunks(for request: URLRequest) async throws -> (any AsyncChunkSequence, URLResponse) func invalidateAndCancel() } extension URLSession: ApolloURLSession { - public func chunks(for request: some GraphQLRequest) async throws -> (any AsyncChunkSequence, URLResponse) { + public func chunks(for request: URLRequest) async throws -> (any AsyncChunkSequence, URLResponse) { try Task.checkCancellation() - let (bytes, response) = try await bytes(for: request.toURLRequest(), delegate: nil) + let (bytes, response) = try await bytes(for: request, delegate: nil) return (bytes.chunks, response) } } diff --git a/apollo-ios/Sources/Apollo/AsyncHTTPResponseChunkSequence.swift b/apollo-ios/Sources/Apollo/AsyncHTTPResponseChunkSequence.swift index b39de54fd..c5eabdf3b 100644 --- a/apollo-ios/Sources/Apollo/AsyncHTTPResponseChunkSequence.swift +++ b/apollo-ios/Sources/Apollo/AsyncHTTPResponseChunkSequence.swift @@ -8,7 +8,7 @@ extension URLSession.AsyncBytes { } -public protocol AsyncChunkSequence: AsyncSequence where Element == Data { +public protocol AsyncChunkSequence: AsyncSequence, Sendable where Element == Data { } diff --git a/apollo-ios/Sources/Apollo/AsyncThrowingStream+ExecutingInAsyncTask.swift b/apollo-ios/Sources/Apollo/AsyncThrowingStream+ExecutingInAsyncTask.swift new file mode 100644 index 000000000..acb45f78e --- /dev/null +++ b/apollo-ios/Sources/Apollo/AsyncThrowingStream+ExecutingInAsyncTask.swift @@ -0,0 +1,32 @@ +import Foundation + +extension AsyncThrowingStream where Failure == any Swift.Error { + static func executingInAsyncTask( + bufferingPolicy: AsyncThrowingStream.Continuation.BufferingPolicy = .unbounded, + _ block: @escaping @Sendable (Continuation) async throws -> Void + ) -> Self { + return AsyncThrowingStream(bufferingPolicy: bufferingPolicy) { continuation in + let task = Task { + do { + try await block(continuation) + continuation.finish() + + } catch { + continuation.finish(throwing: error) + } + } + + continuation.onTermination = { _ in task.cancel() } + } + } +} + +extension AsyncThrowingStream.Continuation { + func passthroughResults( + of stream: AsyncThrowingStream + ) async throws where Element: Sendable { + for try await element in stream { + self.yield(element) + } + } +} diff --git a/apollo-ios/Sources/Apollo/AutoPersistedQueryConfiguration.swift b/apollo-ios/Sources/Apollo/AutoPersistedQueryConfiguration.swift index bce1f011c..87f7e66a8 100644 --- a/apollo-ios/Sources/Apollo/AutoPersistedQueryConfiguration.swift +++ b/apollo-ios/Sources/Apollo/AutoPersistedQueryConfiguration.swift @@ -26,6 +26,7 @@ public struct AutoPersistedQueryConfiguration: Sendable, Hashable { public protocol AutoPersistedQueryCompatibleRequest: GraphQLRequest { +#warning("Consider moving this to ClientContext or RequestConfiguration?") /// 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. diff --git a/apollo-ios/Sources/Apollo/CachePolicy.swift b/apollo-ios/Sources/Apollo/CachePolicy.swift new file mode 100644 index 000000000..23dc3b5ec --- /dev/null +++ b/apollo-ios/Sources/Apollo/CachePolicy.swift @@ -0,0 +1,77 @@ +/// A set of cache policy for requests to an ``ApolloClient`` that specify whether results should be fetched from the +/// server or loaded from the local cache. +/// +/// Cache Policy consists of multiple enums that can be used with different ``ApolloClient`` functions. +/// Different cache policy types can result in different return types for requests. Seperate enums are used to +/// determine what return type ``ApolloClient`` should provide. +public enum CachePolicy: Sendable, Hashable { + + public enum Query: Sendable, Hashable { + public enum SingleResponse: Sendable, Hashable { + /// Return data from the cache if available, else fetch results from the server. + case cacheElseNetwork + /// Attempt to fetch results from the server, if failed, return data from the cache if available. + case networkElseCache + /// Fetch results from the server, do not attempt to read data from the cache. + case networkOnly + } + + public enum CacheOnly: Sendable, Hashable { + /// Return data from the cache if available, do not attempt to fetch results from the server. + case cacheOnly + } + + public enum CacheThenNetwork: Sendable, Hashable { + /// Return data from the cache if available, and always fetch results from the server. + case cacheThenNetwork + } + } + + public enum Subscription: Sendable, Hashable { + /// Return data from the cache if available, and always begin receiving subscription results from the server. + case cacheThenNetwork + /// Begin receiving subscription results from the server, do not attempt to read data from the cache. + case networkOnly + } + +} + +// MARK: - Fetch Behavior Conversion +extension CachePolicy.Query.SingleResponse { + public func toFetchBehavior() -> FetchBehavior { + switch self { + case .cacheElseNetwork: + return FetchBehavior.CacheElseNetwork + + case .networkElseCache: + return FetchBehavior.NetworkElseCache + + case .networkOnly: + return FetchBehavior.NetworkOnly + } + } +} + +extension CachePolicy.Query.CacheOnly { + public func toFetchBehavior() -> FetchBehavior { + return FetchBehavior.CacheOnly + } +} + +extension CachePolicy.Query.CacheThenNetwork { + public func toFetchBehavior() -> FetchBehavior { + return FetchBehavior.CacheThenNetwork + } +} + +extension CachePolicy.Subscription { + public func toFetchBehavior() -> FetchBehavior { + switch self { + case .cacheThenNetwork: + return FetchBehavior.CacheThenNetwork + + case .networkOnly: + return FetchBehavior.NetworkOnly + } + } +} diff --git a/apollo-ios/Sources/Apollo/Cancellable.swift b/apollo-ios/Sources/Apollo/Cancellable.swift index e89377de5..1f14ab621 100644 --- a/apollo-ios/Sources/Apollo/Cancellable.swift +++ b/apollo-ios/Sources/Apollo/Cancellable.swift @@ -1,22 +1,22 @@ +import Combine 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() +public protocol Cancellable: Sendable, Combine.Cancellable { + /// Cancel an in progress action. + func cancel() } // MARK: - URL Session Conformance @available(*, deprecated) -extension URLSessionTask: Cancellable {} +extension URLSessionTask: Apollo.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 { +public final class EmptyCancellable: Apollo.Cancellable { // Needs to be public so this can be instantiated outside of the current framework. public init() {} @@ -26,11 +26,13 @@ public final class EmptyCancellable: Cancellable { } } -// MARK: - Task Conformance +// MARK: - Task Cancellable + +extension Task: Apollo.Cancellable { } #warning("Test that this works. Task is a struct, not a class.") @available(*, deprecated) -public final class TaskCancellable: Cancellable { +public final class TaskCancellable: Combine.Cancellable, Apollo.Cancellable { let task: Task @@ -46,7 +48,7 @@ public final class TaskCancellable: Cancellab // MARK: - CancellationState @available(*, deprecated) -public class CancellationState: Cancellable, @unchecked Sendable { +public class CancellationState: Apollo.Cancellable, @unchecked Sendable { @Atomic var isCancelled: Bool = false diff --git a/apollo-ios/Sources/Apollo/ClientAwarenessMetadata.swift b/apollo-ios/Sources/Apollo/ClientAwarenessMetadata.swift index d2733988a..341e6054f 100644 --- a/apollo-ios/Sources/Apollo/ClientAwarenessMetadata.swift +++ b/apollo-ios/Sources/Apollo/ClientAwarenessMetadata.swift @@ -11,12 +11,12 @@ public struct ClientAwarenessMetadata: Sendable { /// The name of the application. This value is sent for the header "apollographql-client-name". /// /// Defaults to `nil`. - public var clientApplicationName: String? + public let clientApplicationName: String? /// The version of the application. This value is sent for the header "apollographql-client-version". /// /// Defaults to `nil`. - public var clientApplicationVersion: String? + public let clientApplicationVersion: String? /// Determines if the Apollo iOS library name and version should be sent with the telemetry data. /// diff --git a/apollo-ios/Sources/Apollo/FetchBehavior.swift b/apollo-ios/Sources/Apollo/FetchBehavior.swift new file mode 100644 index 000000000..fc45a030d --- /dev/null +++ b/apollo-ios/Sources/Apollo/FetchBehavior.swift @@ -0,0 +1,69 @@ +// MARK: Pre-defined Constants +extension FetchBehavior { + /// Return data from the cache if available, else fetch results from the server. + public static let CacheElseNetwork = FetchBehavior( + cacheRead: .beforeNetworkFetch, + networkFetch: .onCacheMiss + ) + + /// Return data from the cache if available, and always fetch results from the server. + public static let CacheThenNetwork = FetchBehavior( + cacheRead: .beforeNetworkFetch, + networkFetch: .always + ) + + /// Attempt to fetch results from the server, if failed, return data from the cache if available. + public static let NetworkElseCache = FetchBehavior( + cacheRead: .onNetworkFailure, + networkFetch: .always + ) + + /// Return data from the cache if available, do not attempt to fetch results from the server. + public static let CacheOnly = FetchBehavior( + cacheRead: .beforeNetworkFetch, + networkFetch: .never + ) + + /// Fetch results from the server, do not attempt to read data from the cache. + public static let NetworkOnly = FetchBehavior( + cacheRead: .never, + networkFetch: .always + ) +} + +// MARK: - + +/// Describes the cache/networking behaviors that should be usedfor the execution of a GraphQL +/// request. +/// +/// - Discussion: ``CachePolicy`` is designed to be the public facing API for determining these +/// behaviors. It is broken into multiple different types in order to provide the context needed to +/// dispatch to the correct ``ApolloClient`` function. ``ApolloClient`` then converts the +/// ``CachePolicy`` to a ``FetchBehavior`` which it provides to the ``NetworkTransport``. This +/// allows internal components (eg. ``RequestChain``) to operate on a single type for ease of use. +public struct FetchBehavior: Sendable, Hashable { + public enum CacheReadBehavior: Sendable { + case never + case beforeNetworkFetch + case onNetworkFailure + } + + public enum NetworkFetchBehavior: Sendable { + case never + case always + case onCacheMiss + } + + public let cacheRead: CacheReadBehavior + + public let networkFetch: NetworkFetchBehavior + + fileprivate init( + cacheRead: CacheReadBehavior, + networkFetch: NetworkFetchBehavior + ) { + self.cacheRead = cacheRead + self.networkFetch = networkFetch + } + +} diff --git a/apollo-ios/Sources/Apollo/GraphQLQueryWatcher.swift b/apollo-ios/Sources/Apollo/GraphQLQueryWatcher.swift index b2611c7a4..7fcb5eed1 100644 --- a/apollo-ios/Sources/Apollo/GraphQLQueryWatcher.swift +++ b/apollo-ios/Sources/Apollo/GraphQLQueryWatcher.swift @@ -1,15 +1,27 @@ import Foundation + #if !COCOAPODS -import ApolloAPI + import ApolloAPI #endif -/// A `GraphQLQueryWatcher` is responsible for watching the store, and calling the result handler with a new result whenever any of the data the previous result depends on changes. +/// A `GraphQLQueryWatcher` is responsible for watching the store, and calling the result handler with a new result +/// whenever any of query's data changes. /// -/// NOTE: The store retains the watcher while subscribed. You must call `cancel()` on your query watcher when you no longer need results. Failure to call `cancel()` before releasing your reference to the returned watcher will result in a memory leak. -#warning("TODO: can we fix this ^ using a deinit block? Should we? Might cause more confusing bugs for users.") -public final class GraphQLQueryWatcher: Cancellable, ApolloStoreSubscriber { - #warning("TODO: temp nonisolated(unsafe) to move forward; is not thread safe") - nonisolated(unsafe) weak private(set) var client: (any ApolloClientProtocol)? +/// NOTE: The store retains the watcher while subscribed. You must call `cancel()` on your query watcher when you no +/// longer need results. Failure to call `cancel()` before releasing your reference to the returned watcher will result +/// in a memory leak. +public actor GraphQLQueryWatcher: ApolloStoreSubscriber, Apollo.Cancellable { + public typealias ResultHandler = @Sendable (Result, any Swift.Error>) -> Void + private typealias FetchBlock = @Sendable (FetchBehavior, RequestConfiguration?) throws -> AsyncThrowingStream< + GraphQLResult, any Error + >? + + /// The ``GraphQLQuery`` for the watcher. + /// + /// When `fetch(fetchBehavior:requestConfiguration)` is called, this query will be fetched and the `resultHandler` + /// will be called with the results. + /// After the initial fetch, changes in the local cache to any of the query's data will trigger this query + /// to be re-fetched from the cache and the `resultHandler` will be called again with the updated results. public let query: Query /// Determines if the watcher should perform a network fetch when it's watched objects have @@ -18,30 +30,23 @@ public final class GraphQLQueryWatcher: Cancellable, Apollo /// If set to `false`, the watcher will not receive updates if the cache load fails. public let refetchOnFailedUpdates: Bool - let resultHandler: GraphQLResultHandler + public var cancelled: Bool = false - private let callbackQueue: DispatchQueue + private var lastFetch: FetchContext? + private var dependentKeys: Set? = nil + private let resultHandler: ResultHandler private let contextIdentifier = UUID() - private let context: (any RequestContext)? - - private actor WeakFetchTaskContainer { - weak var cancellable: (any Cancellable)? - var cachePolicy: CachePolicy? - var dependentKeys: Set? = nil + private let fetchBlock: FetchBlock + private let cancelBlock: @Sendable (GraphQLQueryWatcher) -> Void + private nonisolated(unsafe) var subscriptionToken: ApolloStore.SubscriptionToken! - fileprivate init(_ cancellable: (any Cancellable)?, _ cachePolicy: CachePolicy?) { - self.cancellable = cancellable - self.cachePolicy = cachePolicy - } - - func mutate(_ block: @Sendable (isolated WeakFetchTaskContainer) -> Void) { - block(self) - } + private struct FetchContext { + let task: Task + let fetchBehavior: FetchBehavior + let requestConfiguration: RequestConfiguration? } - private let fetching: WeakFetchTaskContainer = .init(nil, nil) - /// Designated initializer /// /// - Parameters: @@ -52,118 +57,163 @@ public final class GraphQLQueryWatcher: Cancellable, Apollo /// - context: [optional] A context that is being passed through the request chain. Defaults to `nil`. /// - callbackQueue: The queue for the result handler. Defaults to the main queue. /// - resultHandler: The result handler to call with changes. - public init(client: any ApolloClientProtocol, - query: Query, - refetchOnFailedUpdates: Bool = true, - context: (any RequestContext)? = nil, - callbackQueue: DispatchQueue = .main, - resultHandler: @escaping GraphQLResultHandler) { - self.client = client + public init( + client: ApolloClient, + query: Query, + refetchOnFailedUpdates: Bool = true, + resultHandler: @escaping ResultHandler + ) { self.query = query self.refetchOnFailedUpdates = refetchOnFailedUpdates self.resultHandler = resultHandler - self.callbackQueue = callbackQueue - self.context = context - client.store.subscribe(self) + self.fetchBlock = { [weak client] in + guard let client else { return nil } + + return try client.fetch( + query: query, + fetchBehavior: $0, + requestConfiguration: $1 + ) + } + + self.cancelBlock = { [weak client] (self) in + guard let client else { + return + } + + client.store.unsubscribe(self.subscriptionToken) + Task.detached { + await self.doOnActor { (self) in + self.cancelled = true + } + } + } + + self.subscriptionToken = client.store.subscribe(self) } - /// Refetch a query from the server. - public func refetch(cachePolicy: CachePolicy = .fetchIgnoringCacheData) { - fetch(cachePolicy: cachePolicy) + private func doOnActor(_ block: @escaping @Sendable (isolated GraphQLQueryWatcher) async throws -> Void) async rethrows { + try await block(self) } - func fetch(cachePolicy: CachePolicy) { + // MARK: - Fetch + + public func fetch( + fetchBehavior: FetchBehavior, + requestConfiguration: RequestConfiguration? = nil + ) { QueryWatcherContext.$identifier.withValue(self.contextIdentifier) { - Task { - await fetching.mutate { - // Cancel anything already in flight before starting a new fetch - $0.cancellable?.cancel() - $0.cachePolicy = cachePolicy - - $0.cancellable = client?.fetch( - query: query, - cachePolicy: cachePolicy, - context: self.context, - queue: callbackQueue - ) { [weak self] result in - guard let self = self else { return } - - switch result { - case .success(let graphQLResult): - Task { - await self.fetching.mutate { - $0.dependentKeys = graphQLResult.dependentKeys - } - } - case .failure: - break - } + self.lastFetch?.task.cancel() + + let fetchTask = Task { + do { + try Task.checkCancellation() - self.resultHandler(result) + guard let fetch = try self.fetchBlock(fetchBehavior, requestConfiguration) else { + // Fetch returned nil because the client has been deinitialized. + // Watcher is invalid and should be cancelled. + self.cancel() + return } + + for try await result in fetch { + try Task.checkCancellation() + + if let dependentKeys = result.dependentKeys { + self.dependentKeys = dependentKeys + } + self.didReceiveResult(result) + } + } catch is CancellationError { + // Fetch cancellation. No-op + } catch { + self.didReceiveError(error) } } + + self.lastFetch = FetchContext( + task: fetchTask, + fetchBehavior: fetchBehavior, + requestConfiguration: requestConfiguration + ) } } + public func fetch( + cachePolicy: CachePolicy.Query.SingleResponse, + requestConfiguration: RequestConfiguration? = nil + ) { + self.fetch(fetchBehavior: cachePolicy.toFetchBehavior(), requestConfiguration: requestConfiguration) + } + + public func fetch( + cachePolicy: CachePolicy.Query.CacheOnly, + requestConfiguration: RequestConfiguration? = nil + ) { + self.fetch(fetchBehavior: cachePolicy.toFetchBehavior(), requestConfiguration: requestConfiguration) + } + + public func fetch( + cachePolicy: CachePolicy.Query.CacheThenNetwork, + requestConfiguration: RequestConfiguration? = nil + ) { + self.fetch(fetchBehavior: cachePolicy.toFetchBehavior(), requestConfiguration: requestConfiguration) + } + + // MARK: - Result Handling + + private func didReceiveResult(_ result: GraphQLResult) { + guard !self.cancelled else { return } + resultHandler(.success(result)) + } + + private func didReceiveError(_ error: any Swift.Error) { + guard !self.cancelled else { return } + resultHandler(.failure(error)) + } + /// Cancel any in progress fetching operations and unsubscribe from the store. - public func cancel() { - #warning("TODO: temp Task encapsulation to move forward; not sure if canceling async is safe?") - client?.store.unsubscribe(self) - Task { - await fetching.cancellable?.cancel() - } + public nonisolated consuming func cancel() { + self.cancelBlock(self) } - public func store( + public nonisolated func store( _ store: ApolloStore, didChangeKeys changedKeys: Set ) { - if - let incomingIdentifier = QueryWatcherContext.identifier, - incomingIdentifier == self.contextIdentifier { - // This is from changes to the keys made from the `fetch` method above, - // changes will be returned through that and do not need to be returned - // here as well. - return + if let incomingIdentifier = QueryWatcherContext.identifier, + incomingIdentifier == self.contextIdentifier + { + // This is from changes to the keys made from the `fetch` method above, + // changes will be returned through that and do not need to be returned + // here as well. + return } - Task { [store] in - guard let dependentKeys = await fetching.dependentKeys else { - // This query has nil dependent keys, so nothing that changed will affect it. - return - } + Task { + await self.doOnActor { (self) in + guard !self.cancelled else { return } + guard let dependentKeys = self.dependentKeys else { + // This query has nil dependent keys, so nothing that changed will affect it. + return + } - if !dependentKeys.isDisjoint(with: changedKeys) { - // First, attempt to reload the query from the cache directly, in order not to interrupt any in-flight server-side fetch. - store.load(self.query) { [weak self] result in - guard let self = self else { return } - - switch result { - case .success(let graphQLResult): - self.callbackQueue.async { [weak self] in - guard let self = self else { - return - } - - #warning("TODO: temp Task encapsulation to move forward; this probably is not right?") - Task { - await self.fetching.mutate { - $0.dependentKeys = graphQLResult.dependentKeys - } - self.resultHandler(result) - } - } + if !dependentKeys.isDisjoint(with: changedKeys) { + do { + // First, attempt to reload the query from the cache directly, in order not to interrupt any + // in-flight server-side fetch. + let result = try await store.load(self.query) + self.dependentKeys = result.dependentKeys + self.didReceiveResult(result) - case .failure: -#warning("TODO: temp Task encapsulation to move forward; this probably is not right?") - Task { - let cachePolicy = await fetching.cachePolicy - if self.refetchOnFailedUpdates && cachePolicy != .returnCacheDataDontFetch { - // If the cache fetch is not successful, for instance if the data is missing, refresh from the server. - self.fetch(cachePolicy: .fetchIgnoringCacheData) - } + } catch { + if self.refetchOnFailedUpdates && self.lastFetch?.fetchBehavior.networkFetch != .never { + // If the cache fetch is not successful, for instance if the data is missing, refresh from the server. + self.fetch( + fetchBehavior: FetchBehavior.NetworkOnly, + requestConfiguration: self.lastFetch?.requestConfiguration + ) } } } @@ -175,6 +225,33 @@ public final class GraphQLQueryWatcher: Cancellable, Apollo // MARK: - Task Local Values private enum QueryWatcherContext { - @TaskLocal - static var identifier: UUID? + @TaskLocal static var identifier: UUID? +} + +// MARK: - Deprecation + +extension GraphQLQueryWatcher { + @available(*, deprecated) + @_disfavoredOverload + public init( + client: ApolloClient, + query: Query, + refetchOnFailedUpdates: Bool = true, + context: (any RequestContext)? = nil, + callbackQueue: DispatchQueue = .main, + resultHandler: @escaping GraphQLResultHandler + ) { + self.init( + client: client, + query: query, + refetchOnFailedUpdates: refetchOnFailedUpdates, + resultHandler: resultHandler + ) + } + + @available(*, deprecated, renamed: "fetch(fetchBehavior:)") + public func refetch(cachePolicy: CachePolicy.Query.SingleResponse = .cacheElseNetwork) { + fetch(fetchBehavior: cachePolicy.toFetchBehavior()) + } + } diff --git a/apollo-ios/Sources/Apollo/GraphQLRequest.swift b/apollo-ios/Sources/Apollo/GraphQLRequest.swift index 3ed3524ce..0634852af 100644 --- a/apollo-ios/Sources/Apollo/GraphQLRequest.swift +++ b/apollo-ios/Sources/Apollo/GraphQLRequest.swift @@ -15,16 +15,18 @@ public protocol GraphQLRequest: Sendable { /// 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 } + /// The ``FetchBehavior`` to use for this request. + /// Determines if fetching will include cache/network. + var fetchBehavior: FetchBehavior { get set } - /// [optional] A context that is being passed through the request chain. - var context: (any RequestContext)? { get set } + /// Determines if the results of a network fetch should be written to the local cache. + var writeResultsToCache: Bool { get set } - /// The telemetry metadata about the client. This is used by GraphOS Studio's - /// [client awareness](https://www.apollographql.com/docs/graphos/platform/insights/client-segmentation) - /// feature. - var clientAwarenessMetadata: ClientAwarenessMetadata { get } + /// The timeout interval specifies the limit on the idle interval allotted to a request in the process of + /// loading. This timeout interval is measured in seconds. + /// + /// The value of this property will be set as the `timeoutInterval` on the `URLRequest` created for this GraphQL request. + var requestTimeout: TimeInterval? { get set } /// Converts the receiver into a `URLRequest` to be used for networking operations. /// @@ -34,10 +36,6 @@ public protocol GraphQLRequest: Sendable { func toURLRequest() throws -> URLRequest } -public extension GraphQLRequest { - var clientAwarenessMetadata: ClientAwarenessMetadata { .init() } -} - // MARK: - Helper Functions extension GraphQLRequest { @@ -47,10 +45,9 @@ extension GraphQLRequest { /// This function creates a `URLRequest` with the following behaviors: /// - `url` set to the receiver's `graphQLEndpoint` /// - `httpMethod` set to POST - /// - Client awareness headers from `clientAwarenessMetadata` added to `allHTTPHeaderFields` + /// - Client awareness headers from `ApolloClient.clientAwarenessMetadata` added to `allHTTPHeaderFields` /// - All header's from `additionalHeaders` added to `allHTTPHeaderFields` - /// - If the `context` conforms to `RequestContextTimeoutConfigurable`, the `timeoutInterval` is - /// set to the context's `requestTimeout`. + /// - Sets the `timeoutInterval` to `requestTimeout` if not nil. /// /// - Note: This should be called within the implementation of `toURLRequest()` and the returned request /// can then be modified as necessary before being returned. @@ -61,13 +58,16 @@ extension GraphQLRequest { request.httpMethod = GraphQLHTTPMethod.POST.rawValue - clientAwarenessMetadata.applyHeaders(to: &request) + if let clientAwarenessMetadata = ApolloClient.context?.clientAwarenessMetadata { + clientAwarenessMetadata.applyHeaders(to: &request) + } + for (fieldName, value) in self.additionalHeaders { request.addValue(value, forHTTPHeaderField: fieldName) } - if let configContext = self.context as? any RequestContextTimeoutConfigurable { - request.timeoutInterval = configContext.requestTimeout + if let requestTimeout { + request.timeoutInterval = requestTimeout } return request diff --git a/apollo-ios/Sources/Apollo/GraphQLResponse.swift b/apollo-ios/Sources/Apollo/GraphQLResponse.swift deleted file mode 100644 index 1bdbb650f..000000000 --- a/apollo-ios/Sources/Apollo/GraphQLResponse.swift +++ /dev/null @@ -1,101 +0,0 @@ -#if !COCOAPODS -import ApolloAPI -#endif - -#warning("TODO: kill") -/// Represents a complete GraphQL response received from a server. -public final class GraphQLResponse { - private let base: AnyGraphQLResponse - - public init( - operation: Operation, - body: JSONObject - ) where Operation.Data == Data { - self.base = AnyGraphQLResponse( - body: body, - rootKey: CacheReference.rootCacheReference(for: Operation.operationType), - variables: operation.__variables - ) - } - - /// Parses the response into a `GraphQLResult` and a `RecordSet` depending on the cache policy. The result can be - /// sent to a completion block for a request and the `RecordSet` can be merged into a local cache. - /// - /// - Returns: A tuple of a `GraphQLResult` 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`. - public func parseResult(withCachePolicy cachePolicy: CachePolicy) async throws -> (GraphQLResult, RecordSet?) { - switch cachePolicy { - case .fetchIgnoringCacheCompletely: - // There is no cache, so we don't need to get any info on dependencies. Use fast parsing. - return (try await parseResultFast(), nil) - - default: - return try await parseResult() - } - } - - /// 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 parseResult() async throws -> (GraphQLResult, RecordSet?) { - let accumulator = zip( - DataDictMapper(), - ResultNormalizerFactory.networkResponseDataNormalizer(), - GraphQLDependencyTracker() - ) - let executionResult = try await base.execute( - selectionSet: Data.self, - with: accumulator - ) - - let result = makeResult( - data: executionResult.map { Data(_dataDict: $0.0 ) }, - 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 parseResultFast() async throws -> GraphQLResult { - let accumulator = DataDictMapper() - let data = try await base.execute( - selectionSet: Data.self, - with: accumulator - ) - - return makeResult(data: data.map { Data(_dataDict: $0) }, dependentKeys: nil) - } - - private func makeResult(data: Data?, dependentKeys: Set?) -> GraphQLResult { - return GraphQLResult( - data: data, - extensions: base.parseExtensions(), - errors: base.parseErrors(), - source: .server, - dependentKeys: dependentKeys - ) - } -} - -// MARK: - Equatable Conformance - -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/IncrementalGraphQLResponse.swift b/apollo-ios/Sources/Apollo/IncrementalGraphQLResponse.swift deleted file mode 100644 index b8f99594f..000000000 --- a/apollo-ios/Sources/Apollo/IncrementalGraphQLResponse.swift +++ /dev/null @@ -1,164 +0,0 @@ -import Foundation -#if !COCOAPODS -import ApolloAPI -#endif - -/// Represents an incremental GraphQL response received from a server. -final class IncrementalGraphQLResponse { - public enum ResponseError: Error, LocalizedError, Equatable { - case missingPath - case missingLabel - case missingDeferredSelectionSetType(String, String) - - public var errorDescription: String? { - switch self { - 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)'." - } - } - } - - private let base: AnyGraphQLResponse - - public init(operation: Operation, body: JSONObject) throws { - guard let path = body["path"] as? [JSONValue] else { - throw ResponseError.missingPath - } - - let rootKey = try CacheReference.rootCacheReference(for: Operation.operationType, path: path) - - self.base = AnyGraphQLResponse( - body: body, - rootKey: rootKey, - variables: operation.__variables - ) - } - - /// 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 parseIncrementalResult( - withCachePolicy cachePolicy: CachePolicy - ) async throws -> (IncrementalGraphQLResult, RecordSet?) { - switch cachePolicy { - case .fetchIgnoringCacheCompletely: - // There is no cache, so we don't need to get any info on dependencies. Use fast parsing. - return (try await parseIncrementalResultFast(), nil) - - default: - return try await parseIncrementalResult() - } - } - - private func parseIncrementalResult() 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 parseIncrementalResultFast() 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.body["path"] as? [JSONValue] else { - throw ResponseError.missingPath - } - guard let label = base.body["label"] as? String else { - throw ResponseError.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 ResponseError.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/InterceptorResultStream.swift b/apollo-ios/Sources/Apollo/InterceptorResultStream.swift new file mode 100644 index 000000000..5b2abf3d0 --- /dev/null +++ b/apollo-ios/Sources/Apollo/InterceptorResultStream.swift @@ -0,0 +1,124 @@ +import Foundation + +#warning("TODO: Rename to something that explains what this actually does instead of its current use case.") +public struct InterceptorResultStream: Sendable, ~Copyable { + + private let stream: AsyncThrowingStream + + public init(stream: AsyncThrowingStream) { + self.stream = stream + } + + public init(stream wrapped: sending S) where S.Element == T { + self.stream = AsyncThrowingStream.executingInAsyncTask { [wrapped] continuation in + for try await element in wrapped { + continuation.yield(element) + } + } + } + + public consuming func map( + _ transform: @escaping @Sendable (T) async throws -> T + ) async throws -> InterceptorResultStream { + let stream = self.stream + + let newStream = AsyncThrowingStream.executingInAsyncTask { continuation in + for try await result in stream { + try Task.checkCancellation() + + try await continuation.yield(transform(result)) + } + } + + return Self.init(stream: newStream) + } + + public consuming func compactMap( + _ transform: @escaping @Sendable (T) async throws -> T? + ) async throws -> InterceptorResultStream { + let stream = self.stream + + let newStream = AsyncThrowingStream.executingInAsyncTask { continuation in + for try await result in stream { + try Task.checkCancellation() + + guard let newResult = try await transform(result) else { + continue + } + + continuation.yield(newResult) + } + } + + return Self.init(stream: newStream) + } + + public consuming func getResults() -> AsyncThrowingStream { + return stream + } + + // MARK: - Error Handling + + #warning("TODO: Write unit tests for this. Docs: if return nil, error is supressed and stream finishes.") + public consuming func mapErrors( + _ transform: @escaping @Sendable (any Error) async throws -> T? + ) async throws -> InterceptorResultStream { + let stream = self.stream + + let newStream = AsyncThrowingStream.executingInAsyncTask { continuation in + do { + for try await result in stream { + try Task.checkCancellation() + + continuation.yield(result) + } + + } catch { + do { + if let recoveryResult = try await transform(error) { + continuation.yield(recoveryResult) + } + + } catch { + continuation.finish(throwing: error) + } + } + } + + return Self.init(stream: newStream) + } + +} + +#warning("Do we keep this? Helps make TaskLocalValues work, but extension on Swift standard lib type could conflict with other extensions") +extension TaskLocal { + + @_disfavoredOverload + final public func withValue( + _ valueDuringOperation: Value, + operation: () async throws -> R + ) async rethrows -> R { + var returnValue: R? + + try await self.withValue(valueDuringOperation) { + returnValue = try await operation() + } + + return returnValue! + } + + @_disfavoredOverload + final public func withValue( + _ valueDuringOperation: Value, + operation: () throws -> R + ) rethrows -> R { + var returnValue: R? + + try self.withValue(valueDuringOperation) { + returnValue = try operation() + } + + return returnValue! + } + +} diff --git a/apollo-ios/Sources/Apollo/Interceptors/ApolloErrorInterceptor.swift b/apollo-ios/Sources/Apollo/Interceptors/ApolloErrorInterceptor.swift deleted file mode 100644 index 9ba2a6008..000000000 --- a/apollo-ios/Sources/Apollo/Interceptors/ApolloErrorInterceptor.swift +++ /dev/null @@ -1,23 +0,0 @@ -#if !COCOAPODS -import ApolloAPI -#endif - -/// An error interceptor called to allow further examination of error data when an error occurs in the chain. -#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: - /// - error: The received error - /// - chain: The chain the error was received on - /// - 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 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 index 1ac444ab0..4a5c21f4c 100644 --- a/apollo-ios/Sources/Apollo/Interceptors/ApolloInterceptor.swift +++ b/apollo-ios/Sources/Apollo/Interceptors/ApolloInterceptor.swift @@ -1,38 +1,28 @@ -import Foundation import Combine +import Foundation + #if !COCOAPODS -import ApolloAPI + import ApolloAPI #endif -public struct InterceptorResult: Sendable, Equatable { - - public let response: HTTPURLResponse - - /// This is the data for a single chunk of the response body. - /// - /// 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 be 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? - } - +public protocol ResponseParsingInterceptor: Sendable { + func parse( + response: consuming HTTPResponse, + for request: Request, + includeCacheRecords: Bool + ) async throws -> InterceptorResultStream> } -#warning("TODO: Wrap RequestChain apis in SPI?") +public struct GraphQLResponse: Sendable, Hashable { + public let result: GraphQLResult + public let cacheRecords: RecordSet? +} /// 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 + typealias NextInterceptorFunction = @Sendable (Request) async throws -> + InterceptorResultStream> /// Called when this interceptor should do its work. /// @@ -44,73 +34,33 @@ public protocol ApolloInterceptor: Sendable { func intercept( request: Request, next: NextInterceptorFunction - ) async throws -> InterceptorResultStream + ) 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() +public struct HTTPResponse: Sendable, ~Copyable { + public let response: HTTPURLResponse - try await continuation.yield(transform(result)) - } - continuation.finish() - - } catch { - continuation.finish(throwing: error) - } - } + public let chunks: InterceptorResultStream - continuation.onTermination = { _ in task.cancel() } + public consuming func mapChunks( + _ transform: @escaping @Sendable (HTTPURLResponse, Data) async throws -> (Data) + ) async throws -> HTTPResponse { + let response = self.response + let stream = try await chunks.map { chunk in + return try await transform(response, chunk) } - return Self.init(stream: newStream) + return HTTPResponse(response: response, chunks: stream) } +} - 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) - } - } +public protocol HTTPInterceptor: Sendable { - continuation.onTermination = { _ in task.cancel() } - } - return Self.init(stream: newStream) - } + typealias NextHTTPInterceptorFunction = @Sendable (URLRequest) async throws -> HTTPResponse - public consuming func getResults() -> AsyncThrowingStream, any Error> { - return stream - } + func intercept( + request: URLRequest, + next: NextHTTPInterceptorFunction + ) async throws -> HTTPResponse } diff --git a/apollo-ios/Sources/Apollo/Interceptors/AutomaticPersistedQueryInterceptor.swift b/apollo-ios/Sources/Apollo/Interceptors/AutomaticPersistedQueryInterceptor.swift index 0fcafce22..ebbb78d18 100644 --- a/apollo-ios/Sources/Apollo/Interceptors/AutomaticPersistedQueryInterceptor.swift +++ b/apollo-ios/Sources/Apollo/Interceptors/AutomaticPersistedQueryInterceptor.swift @@ -6,14 +6,11 @@ import ApolloAPI 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." + switch self { case .persistedQueryRetryFailed(let operationName): return "Persisted query retry failed for operation \"\(operationName)\"." @@ -39,7 +36,7 @@ public struct AutomaticPersistedQueryInterceptor: ApolloInterceptor { public func intercept( request: Request, next: NextInterceptorFunction - ) async throws -> InterceptorResultStream { + ) async throws -> InterceptorResultStream> { guard let jsonRequest = request as? JSONRequest, jsonRequest.apqConfig.autoPersistQueries else { // Not a request that handles APQs, continue along @@ -48,25 +45,22 @@ public struct AutomaticPersistedQueryInterceptor: ApolloInterceptor { 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 + return try await next(request).map { response in +#warning("TODO: Test if cache returns result, then server returns failed result, APQ retry still occurs") + guard response.result.source == .server, + await isInitialResult.get() else { + return response } - guard let errors = parsedResult.result.errors else { + guard let errors = response.result.errors else { // No errors were returned so no retry is necessary, continue along. - return result + return response } let errorMessages = errors.compactMap { $0.message } guard errorMessages.contains("PersistedQueryNotFound") else { // The errors were not APQ errors, continue along. - return result + return response } guard !jsonRequest.isPersistedQueryRetry else { @@ -83,7 +77,9 @@ public struct AutomaticPersistedQueryInterceptor: ApolloInterceptor { var jsonRequest = jsonRequest // We need to retry this query with the full body. jsonRequest.isPersistedQueryRetry = true - throw RequestChainRetry(request: jsonRequest) + jsonRequest.fetchBehavior = FetchBehavior.NetworkOnly + + throw RequestChain.Retry(request: jsonRequest) } } } diff --git a/apollo-ios/Sources/Apollo/Interceptors/CacheInterceptor.swift b/apollo-ios/Sources/Apollo/Interceptors/CacheInterceptor.swift index ac34050ed..22c0024cd 100644 --- a/apollo-ios/Sources/Apollo/Interceptors/CacheInterceptor.swift +++ b/apollo-ios/Sources/Apollo/Interceptors/CacheInterceptor.swift @@ -1,41 +1,41 @@ #if !COCOAPODS -import ApolloAPI + import ApolloAPI #endif public protocol CacheInterceptor: Sendable { - func readCacheData( - for query: Query - ) async throws -> GraphQLResult + func readCacheData( + from store: ApolloStore, + request: Request + ) async throws -> GraphQLResult? - func writeCacheData( - cacheRecords: RecordSet, - for operation: Operation, - with result: GraphQLResult + func writeCacheData( + to store: ApolloStore, + request: Request, + response: GraphQLResponse, ) async throws } public struct DefaultCacheInterceptor: CacheInterceptor { - public let store: ApolloStore + public init() {} - public init(store: ApolloStore) { - self.store = store + public func readCacheData( + from store: ApolloStore, + request: Request + ) async throws -> GraphQLResult? { + return try await store.load(request.operation) } - public func readCacheData( - for query: Query - ) async throws -> GraphQLResult { - return try await store.load(query) + public func writeCacheData( + to store: ApolloStore, + request: Request, + response: GraphQLResponse, + ) async throws { + if let records = response.cacheRecords { + try await store.publish(records: records) + } } - public func writeCacheData( - cacheRecords: RecordSet, - for operation: Operation, - with result: GraphQLResult - ) async throws { - try await store.publish(records: cacheRecords) - } - } diff --git a/apollo-ios/Sources/Apollo/Interceptors/DefaultInterceptorProvider.swift b/apollo-ios/Sources/Apollo/Interceptors/DefaultInterceptorProvider.swift deleted file mode 100644 index 32dce495b..000000000 --- a/apollo-ios/Sources/Apollo/Interceptors/DefaultInterceptorProvider.swift +++ /dev/null @@ -1,63 +0,0 @@ -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/Interceptors/InterceptorProvider.swift b/apollo-ios/Sources/Apollo/Interceptors/InterceptorProvider.swift index f6924630b..a5e245eec 100644 --- a/apollo-ios/Sources/Apollo/Interceptors/InterceptorProvider.swift +++ b/apollo-ios/Sources/Apollo/Interceptors/InterceptorProvider.swift @@ -6,29 +6,50 @@ import ApolloAPI /// A protocol to allow easy creation of an array of interceptors for a given operation. 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 graphQLInterceptors(for request: Request) -> [any ApolloInterceptor] - func cacheInterceptor(for operation: Operation) -> any CacheInterceptor + func cacheInterceptor(for request: Request) -> 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 errorInterceptor(for operation: Operation) -> (any ApolloErrorInterceptor)? + func httpInterceptors(for request: Request) -> [any HTTPInterceptor] + + func responseParser(for request: Request) -> any ResponseParsingInterceptor } -/// MARK: - Default Implementation +// MARK: - Default Implementation + +extension InterceptorProvider { + + /// The default interceptor provider. + static var `default`: some InterceptorProvider { + DefaultInterceptorProvider() + } + + public func graphQLInterceptors(for request: Request) -> [any ApolloInterceptor] { + return [ + MaxRetryInterceptor(), + AutomaticPersistedQueryInterceptor(), + ] + } -public extension InterceptorProvider { - - func errorInterceptor( - for operation: Operation - ) -> (any ApolloErrorInterceptor)? { - return nil + public func httpInterceptors(for request: Request) -> [any HTTPInterceptor] { + return [ + ResponseCodeInterceptor() + ] } + + public func cacheInterceptor(for request: Request) -> any CacheInterceptor { + DefaultCacheInterceptor() + } + + public func responseParser(for request: Request) -> any ResponseParsingInterceptor { + JSONResponseParsingInterceptor() + } +} + +final class DefaultInterceptorProvider: InterceptorProvider { + init() {} } diff --git a/apollo-ios/Sources/Apollo/Interceptors/JSONResponseParsingInterceptor.swift b/apollo-ios/Sources/Apollo/Interceptors/JSONResponseParsingInterceptor.swift index 245ff5922..f6dd2d6d0 100644 --- a/apollo-ios/Sources/Apollo/Interceptors/JSONResponseParsingInterceptor.swift +++ b/apollo-ios/Sources/Apollo/Interceptors/JSONResponseParsingInterceptor.swift @@ -1,63 +1,58 @@ import Foundation -import Combine + #if !COCOAPODS -import ApolloAPI + 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() - - 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 +public struct JSONResponseParsingInterceptor: ResponseParsingInterceptor { + + public init() {} + + public func parse( + response: consuming HTTPResponse, + for request: Request, + includeCacheRecords: Bool + ) async throws -> InterceptorResultStream> { + + let parser = JSONResponseParser( + response: response.response, + operationVariables: request.operation.__variables, + includeCacheRecords: includeCacheRecords + ) + + let chunks = response.chunks.getResults() + + let stream = AsyncThrowingStream, any Error> { continuation in + let task = Task<(), Never> { + do { + defer { continuation.finish() } + var currentResult: GraphQLResponse? + + for try await chunk in chunks { + try Task.checkCancellation() + + guard + let parsedResponse = try await parser.parse( + dataChunk: chunk, + mergingIncrementalItemsInto: currentResult + ) + else { + continue + } + + currentResult = parsedResponse + continuation.yield(parsedResponse) + } + + + } catch { + continuation.finish(throwing: error) + } } - 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 + continuation.onTermination = { _ in task.cancel() } } + return InterceptorResultStream>(stream: stream) } } diff --git a/apollo-ios/Sources/Apollo/Interceptors/MaxRetryInterceptor.swift b/apollo-ios/Sources/Apollo/Interceptors/MaxRetryInterceptor.swift index 27c28ecfa..eaab2d300 100644 --- a/apollo-ios/Sources/Apollo/Interceptors/MaxRetryInterceptor.swift +++ b/apollo-ios/Sources/Apollo/Interceptors/MaxRetryInterceptor.swift @@ -28,7 +28,7 @@ public actor MaxRetryInterceptor: ApolloInterceptor, Sendable { public func intercept( request: Request, next: NextInterceptorFunction - ) async throws -> InterceptorResultStream { + ) async throws -> InterceptorResultStream> { guard self.hitCount <= self.maxRetries else { throw MaxRetriesError( count: self.maxRetries, diff --git a/apollo-ios/Sources/Apollo/Interceptors/ResponseCodeInterceptor.swift b/apollo-ios/Sources/Apollo/Interceptors/ResponseCodeInterceptor.swift index e27415995..da0cf9321 100644 --- a/apollo-ios/Sources/Apollo/Interceptors/ResponseCodeInterceptor.swift +++ b/apollo-ios/Sources/Apollo/Interceptors/ResponseCodeInterceptor.swift @@ -4,13 +4,13 @@ import ApolloAPI #endif /// An interceptor to check the response code returned with a request. -public struct ResponseCodeInterceptor: ApolloInterceptor { +public struct ResponseCodeInterceptor: HTTPInterceptor { public var id: String = UUID().uuidString public struct ResponseCodeError: Error, LocalizedError { public let response: HTTPURLResponse - public let responseChunk: Data + public let chunk: Data public var errorDescription: String? { return "Received a \(response.statusCode) error." @@ -18,7 +18,7 @@ public struct ResponseCodeInterceptor: ApolloInterceptor { public var graphQLError: GraphQLError? { if let jsonValue = try? (JSONSerialization.jsonObject( - with: responseChunk, + with: chunk, options: .allowFragments) as! JSONValue), let jsonObject = try? JSONObject(_jsonValue: jsonValue) { @@ -31,19 +31,18 @@ public struct ResponseCodeInterceptor: ApolloInterceptor { /// 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 { + public func intercept( + request: URLRequest, + next: NextHTTPInterceptorFunction + ) async throws -> HTTPResponse { + return try await next(request).mapChunks { (response, chunk) in + guard response.isSuccessful == true else { throw ResponseCodeError( - response: result.response, - responseChunk: result.rawResponseChunk + response: response, + chunk: chunk ) } - return result + return chunk } } } diff --git a/apollo-ios/Sources/Apollo/JSONRequest.swift b/apollo-ios/Sources/Apollo/JSONRequest.swift index 836d9bcb5..e569f4a90 100644 --- a/apollo-ios/Sources/Apollo/JSONRequest.swift +++ b/apollo-ios/Sources/Apollo/JSONRequest.swift @@ -1,6 +1,7 @@ import Foundation + #if !COCOAPODS -@_spi(Internal) import ApolloAPI + @_spi(Internal) import ApolloAPI #endif /// A request which sends JSON related to a GraphQL operation. @@ -15,12 +16,18 @@ public struct JSONRequest: GraphQLRequest, AutoPers /// 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 + /// The `FetchBehavior` to use for this request. Determines if fetching will include cache/network. + public var fetchBehavior: FetchBehavior + + /// Determines if the results of a network fetch should be written to the local cache. + public var writeResultsToCache: Bool + + /// The timeout interval specifies the limit on the idle interval allotted to a request in the process of + /// loading. This timeout interval is measured in seconds. + /// + /// The value of this property will be set as the `timeoutInterval` on the `URLRequest` created for this GraphQL request. + public var requestTimeout: TimeInterval? - /// [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 @@ -35,11 +42,6 @@ public struct JSONRequest: GraphQLRequest, AutoPers /// Mutation operations always use POST, even when this is `false` public let useGETForQueries: Bool - /// The telemetry metadata about the client. This is used by GraphOS Studio's - /// [client awareness](https://www.apollographql.com/docs/graphos/platform/insights/client-segmentation) - /// feature. - public var clientAwarenessMetadata: ClientAwarenessMetadata - /// Designated initializer /// /// - Parameters: @@ -48,7 +50,6 @@ public struct JSONRequest: GraphQLRequest, AutoPers /// - 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 /// - cachePolicy: The `CachePolicy` to use for this request. - /// - context: [optional] A context that is being passed through the request chain. Defaults to `nil`. /// - 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. @@ -57,39 +58,41 @@ public struct JSONRequest: GraphQLRequest, AutoPers public init( operation: Operation, graphQLEndpoint: URL, - cachePolicy: CachePolicy = .default, - context: (any RequestContext)? = nil, + fetchBehavior: FetchBehavior, + writeResultsToCache: Bool, + requestTimeout: TimeInterval?, apqConfig: AutoPersistedQueryConfiguration = .init(), useGETForQueries: Bool = false, - requestBodyCreator: any JSONRequestBodyCreator = DefaultRequestBodyCreator(), - clientAwarenessMetadata: ClientAwarenessMetadata = ClientAwarenessMetadata() + requestBodyCreator: any JSONRequestBodyCreator = DefaultRequestBodyCreator() ) { self.operation = operation self.graphQLEndpoint = graphQLEndpoint - self.cachePolicy = cachePolicy - self.context = context + self.requestTimeout = requestTimeout self.requestBodyCreator = requestBodyCreator + self.fetchBehavior = fetchBehavior + self.writeResultsToCache = writeResultsToCache self.apqConfig = apqConfig self.useGETForQueries = useGETForQueries - self.clientAwarenessMetadata = clientAwarenessMetadata self.setupDefaultHeaders() } private mutating func setupDefaultHeaders() { - self.addHeader(name: "Content-Type", value: "application/json") + self.addHeader(name: "Content-Type", value: "application/json") if Operation.operationType == .subscription { self.addHeader( name: "Accept", - value: "multipart/mixed;\(MultipartResponseSubscriptionParser.protocolSpec),application/graphql-response+json,application/json" + 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" + value: + "multipart/mixed;\(MultipartResponseDeferParser.protocolSpec),application/graphql-response+json,application/json" ) } } @@ -105,22 +108,21 @@ public struct JSONRequest: GraphQLRequest, AutoPers if isPersistedQueryRetry { useGetMethod = self.apqConfig.useGETForPersistedQueryRetry } else { - useGetMethod = self.useGETForQueries || - (self.apqConfig.autoPersistQueries && self.apqConfig.useGETForPersistedQueryRetry) + useGetMethod = + self.useGETForQueries || (self.apqConfig.autoPersistQueries && self.apqConfig.useGETForPersistedQueryRetry) } default: useGetMethod = false } - + let httpMethod: GraphQLHTTPMethod = useGetMethod ? .GET : .POST - + switch httpMethod { case .GET: let transformer = GraphQLGETTransformer(body: body, url: self.graphQLEndpoint) if let urlForGet = transformer.createGetURL() { request.url = urlForGet request.httpMethod = GraphQLHTTPMethod.GET.rawValue - request.cachePolicy = requestCachePolicy // GET requests shouldn't have a content-type since they do not provide actual content. request.allHTTPHeaderFields?.removeValue(forKey: "Content-Type") @@ -135,7 +137,7 @@ public struct JSONRequest: GraphQLRequest, AutoPers throw GraphQLHTTPRequestError.serializedBodyMessageError } } - + return request } @@ -164,53 +166,41 @@ public struct JSONRequest: GraphQLRequest, AutoPers sendQueryDocument = true autoPersistQueries = false } - - var body = self.requestBodyCreator.requestBody( - for: self, + + let body = self.requestBodyCreator.requestBody( + for: self.operation, sendQueryDocument: sendQueryDocument, autoPersistQuery: autoPersistQueries - ) + ) return body } - /// Convert the Apollo iOS cache policy into a matching cache policy for URLRequest. - private var requestCachePolicy: URLRequest.CachePolicy { - switch cachePolicy { - case .returnCacheDataElseFetch: - return .useProtocolCachePolicy - case .fetchIgnoringCacheData: - return .reloadIgnoringLocalCacheData - case .fetchIgnoringCacheCompletely: - return .reloadIgnoringLocalAndRemoteCacheData - case .returnCacheDataDontFetch: - return .returnCacheDataDontLoad - case .returnCacheDataAndFetch: - return .reloadRevalidatingCacheData - } - } - // MARK: - Equtable/Hashable Conformance 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.apqConfig == rhs.apqConfig && - lhs.isPersistedQueryRetry == rhs.isPersistedQueryRetry && - lhs.useGETForQueries == rhs.useGETForQueries && - type(of: lhs.requestBodyCreator) == type(of: rhs.requestBodyCreator) + lhs.graphQLEndpoint == rhs.graphQLEndpoint + && lhs.operation == rhs.operation + && lhs.additionalHeaders == rhs.additionalHeaders + && lhs.fetchBehavior == rhs.fetchBehavior + && lhs.writeResultsToCache == rhs.writeResultsToCache + && lhs.requestTimeout == rhs.requestTimeout + && lhs.apqConfig == rhs.apqConfig + && lhs.isPersistedQueryRetry == rhs.isPersistedQueryRetry + && lhs.useGETForQueries == rhs.useGETForQueries + && type(of: lhs.requestBodyCreator) == type(of: rhs.requestBodyCreator) } public func hash(into hasher: inout Hasher) { hasher.combine(graphQLEndpoint) hasher.combine(operation) hasher.combine(additionalHeaders) - hasher.combine(cachePolicy) + hasher.combine(fetchBehavior) + hasher.combine(writeResultsToCache) + hasher.combine(requestTimeout) hasher.combine(apqConfig) hasher.combine(isPersistedQueryRetry) hasher.combine(useGETForQueries) @@ -229,7 +219,8 @@ extension JSONRequest: CustomDebugStringConvertible { debugStrings.append("\t\(key): \(value),") } debugStrings.append("]") - debugStrings.append("Cache Policy: \(self.cachePolicy)") + debugStrings.append("Fetch Behavior: \(self.fetchBehavior)") + debugStrings.append("Write Results to Cache: \(self.writeResultsToCache)") debugStrings.append("Operation: \(self.operation)") return debugStrings.joined(separator: "\n\t") } diff --git a/apollo-ios/Sources/Apollo/NetworkTransport.swift b/apollo-ios/Sources/Apollo/NetworkTransport.swift index b253ff7ff..cadef93ed 100644 --- a/apollo-ios/Sources/Apollo/NetworkTransport.swift +++ b/apollo-ios/Sources/Apollo/NetworkTransport.swift @@ -8,23 +8,21 @@ public protocol NetworkTransport: AnyObject, Sendable { /// Send a GraphQL operation to a server and return a response. /// - /// Note if you're implementing this yourself rather than using one of the batteries-included versions of `NetworkTransport` (which handle this for you): The `clientName` and `clientVersion` should be sent with any URL request which needs headers so your client can be identified by tools meant to see what client is using which request. The `addApolloClientHeaders` method is provided below to do this for you if you're using Apollo Studio. - /// /// - Parameters: /// - operation: The operation to send. - /// - cachePolicy: The `CachePolicy` to use making this request. - /// - context: [optional] A context that is being passed through the request chain. Defaults to `nil`. + /// - fetchBehavior: The `FetchBehavior` to use for this request. + /// Determines if fetching will include cache/network fetches. + /// - requestConfiguration: A configuration used to configure per-request behaviors for this request /// - Returns: A stream of `GraphQLResult`s for each response. func send( query: Query, - cachePolicy: CachePolicy, - context: (any RequestContext)? + fetchBehavior: FetchBehavior, + requestConfiguration: RequestConfiguration ) throws -> AsyncThrowingStream, any Error> func send( mutation: Mutation, - cachePolicy: CachePolicy, - context: (any RequestContext)? + requestConfiguration: RequestConfiguration ) throws -> AsyncThrowingStream, any Error> } @@ -35,8 +33,8 @@ public protocol SubscriptionNetworkTransport: NetworkTransport { func send( subscription: Subscription, - cachePolicy: CachePolicy, - context: (any RequestContext)? + fetchBehavior: FetchBehavior, + requestConfiguration: RequestConfiguration ) throws -> AsyncThrowingStream, any Error> } @@ -51,12 +49,11 @@ public protocol UploadingNetworkTransport: NetworkTransport { /// - Parameters: /// - 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. + /// - requestConfiguration: A configuration used to configure per-request behaviors for this request /// - Returns: A stream of `GraphQLResult`s for each response. -#warning("TODO: should support query and mutation as seperate functions") func upload( operation: Operation, files: [GraphQLFile], - context: (any RequestContext)? + requestConfiguration: RequestConfiguration ) throws -> AsyncThrowingStream, any Error> } diff --git a/apollo-ios/Sources/Apollo/NormalizedCache.swift b/apollo-ios/Sources/Apollo/NormalizedCache.swift index 2ac7ace17..68ddef56f 100644 --- a/apollo-ios/Sources/Apollo/NormalizedCache.swift +++ b/apollo-ios/Sources/Apollo/NormalizedCache.swift @@ -22,7 +22,7 @@ public protocol NormalizedCache: AnyObject { /// record to remove based on that key. /// /// This method does not support cascading delete - it will only - /// remove the record for the specified key, and not any references to it or from it.z + /// remove the record for the specified key, and not any references to it or from it. /// /// - Parameters: /// - key: The cache key to remove the record for diff --git a/apollo-ios/Sources/Apollo/RequestBodyCreator.swift b/apollo-ios/Sources/Apollo/RequestBodyCreator.swift index 446175e9f..48cbca136 100644 --- a/apollo-ios/Sources/Apollo/RequestBodyCreator.swift +++ b/apollo-ios/Sources/Apollo/RequestBodyCreator.swift @@ -7,6 +7,7 @@ public struct DefaultRequestBodyCreator: JSONRequestBodyCreator { public init() { } } +#warning("TODO: Do we really need this? Should it be part of RequestChainNetworkTransport, or just on JSONRequest") public protocol JSONRequestBodyCreator: Sendable { #warning("TODO: replace with version that takes request after rewriting websocket") /// Creates a `JSONEncodableDictionary` out of the passed-in operation @@ -26,8 +27,7 @@ public protocol JSONRequestBodyCreator: Sendable { func requestBody( for operation: Operation, sendQueryDocument: Bool, - autoPersistQuery: Bool, - clientAwarenessMetadata: ClientAwarenessMetadata + autoPersistQuery: Bool ) -> JSONEncodableDictionary } @@ -36,31 +36,10 @@ public protocol JSONRequestBodyCreator: Sendable { 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, - clientAwarenessMetadata: ClientAwarenessMetadata + autoPersistQuery: Bool ) -> JSONEncodableDictionary { var body: JSONEncodableDictionary = [ "operationName": Operation.operationName, @@ -87,7 +66,9 @@ extension JSONRequestBodyCreator { ] } - clientAwarenessMetadata.applyExtension(to: &body) + if let clientAwarenessMetadata = ApolloClient.context?.clientAwarenessMetadata { + clientAwarenessMetadata.applyExtension(to: &body) + } return body } diff --git a/apollo-ios/Sources/Apollo/RequestChain.swift b/apollo-ios/Sources/Apollo/RequestChain.swift index f32b0ac1c..77f09dfe9 100644 --- a/apollo-ios/Sources/Apollo/RequestChain.swift +++ b/apollo-ios/Sources/Apollo/RequestChain.swift @@ -4,25 +4,11 @@ import Foundation import ApolloAPI #endif -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" @@ -31,55 +17,50 @@ public enum RequestChainError: Swift.Error, LocalizedError { } -struct RequestChain: Sendable { +public struct RequestChain: Sendable { + + public struct Retry: Swift.Error { + /// The request to be retried. + public let request: Request + + public init(request: Request) { + self.request = request + } + } private let urlSession: any ApolloURLSession - private let interceptors: [any ApolloInterceptor] - private let cacheInterceptor: any CacheInterceptor - private let errorInterceptor: (any ApolloErrorInterceptor)? + private let interceptorProvider: any InterceptorProvider + private let store: ApolloStore - typealias Operation = Request.Operation - typealias ResultStream = AsyncThrowingStream, any Error> + public typealias ResultStream = AsyncThrowingStream, any Error> + public typealias Operation = Request.Operation /// 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( + /// - Parameters: TODO + public init( urlSession: any ApolloURLSession, - interceptors: [any ApolloInterceptor], - cacheInterceptor: any CacheInterceptor, - errorInterceptor: (any ApolloErrorInterceptor)? + interceptorProvider: any InterceptorProvider, + store: ApolloStore ) { self.urlSession = urlSession - self.interceptors = interceptors - self.cacheInterceptor = cacheInterceptor - self.errorInterceptor = errorInterceptor + self.interceptorProvider = interceptorProvider + self.store = store + } /// 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( + /// - fetchBehavior: The ``FetchBehavior`` to use for this request. Determines if fetching will include cache/network. + /// - shouldAttemptCacheWrite: Determines if the results of a network fetch should be written to the local cache. + public func kickoff( request: Request ) -> ResultStream { return doInRetryingAsyncThrowingStream(request: request) { request, continuation in - try await kickoffRequestInterceptors(for: request, continuation: continuation) + #warning("TODO: Write unit test that cache only request gets sent through interceptors still.") + try await kickoffRequestInterceptors(request: request, continuation: continuation) } } @@ -87,22 +68,9 @@ struct RequestChain: Sendable { 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() + return AsyncThrowingStream.executingInAsyncTask { continuation in + try await doHandlingRetries(request: request) { request in + try await body(request, continuation) } } } @@ -114,40 +82,24 @@ struct RequestChain: Sendable { do { try await body(request) - } catch let error as RequestChainRetry { + } catch let error as Retry { 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, + request initialRequest: Request, continuation: ResultStream.Continuation ) async throws { + let interceptors = self.interceptorProvider.graphQLInterceptors(for: initialRequest) + + // Setup next function to traverse interceptors nonisolated(unsafe) var finalRequest: Request! - var next: @Sendable (Request) async throws -> InterceptorResultStream = { request in + var next: @Sendable (Request) async throws -> InterceptorResultStream> = { + request in finalRequest = request - return try await executeNetworkFetch(request: request) + + return execute(request: request) } for interceptor in interceptors.reversed() { @@ -158,18 +110,17 @@ struct RequestChain: Sendable { } } + // Kickoff first interceptor 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 - } + for try await response in resultStream.getResults() { + try Task.checkCancellation() - try await writeToCacheIfNecessary(result: result, for: finalRequest) + try await writeToCacheIfNecessary(response: response, for: finalRequest) - continuation.yield(result.result) + continuation.yield(response.result) didEmitResult = true } @@ -178,100 +129,166 @@ struct RequestChain: Sendable { } } - private func executeNetworkFetch( + #warning("TODO: unit tests for cache read after failed network fetch") + private func execute( request: Request - ) async throws -> InterceptorResultStream { + ) -> InterceptorResultStream> { return InterceptorResultStream( - stream: AsyncThrowingStream { continuation in - let task = Task { - do { - let (chunks, response) = try await urlSession.chunks(for: request) + stream: AsyncThrowingStream, any Error>.executingInAsyncTask { continuation in + let fetchBehavior = request.fetchBehavior + var didYieldCacheData: Bool = false - 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." + // If read from cache before network fetch + if fetchBehavior.shouldReadFromCache(hadFailedNetworkFetch: false) { + do { + if let cacheResult = try await attemptCacheRead(request: request) { + // Successful cache read + didYieldCacheData = true + continuation.yield( + GraphQLResponse(result: cacheResult, cacheRecords: nil) ) } - for try await chunk in chunks { - continuation.yield( - InterceptorResult( - response: response, - rawResponseChunk: chunk as! Data - ) - ) + // Cache miss + + } catch { + #warning( + """ + TODO: If we are making cache miss return nil (instead of throwing error), then should + this just always be throwing the error? What's the point of differentiating cache miss + from thrown error if we are still supressing it here? + + An error interceptor can still catch on the error and run a retry with a fetch behavior that doesn't do a cache read on the cache failure + """ + ) + // Cache read failure + if !fetchBehavior.shouldFetchFromNetwork(hadSuccessfulCacheRead: false) { + throw error } + } + } + + // If should perform network fetch (based on cache result) + if fetchBehavior.shouldFetchFromNetwork(hadSuccessfulCacheRead: didYieldCacheData) { + do { + let networkStream = try await kickOffHTTPInterceptors(request: request) + try await continuation.passthroughResults(of: networkStream.getResults()) + + // Successful network fetch -> Finished - continuation.finish() } catch { - continuation.finish(throwing: error) + // Network fetch throws error + if fetchBehavior.shouldReadFromCache(hadFailedNetworkFetch: true) { + // Attempt recovery with cache read + if let cacheResult = try await attemptCacheRead(request: request) { + // Successful cache read + continuation.yield( + GraphQLResponse(result: cacheResult, cacheRecords: nil) + ) + } + + } else { + throw error + } } } + } + ) + } + + private func attemptCacheRead( + request: Request + ) async throws -> GraphQLResult? { + let cacheInterceptor = self.interceptorProvider.cacheInterceptor(for: request) + return try await cacheInterceptor.readCacheData(from: self.store, request: request) + } + + private func kickOffHTTPInterceptors( + request graphQLRequest: Request + ) async throws -> InterceptorResultStream> { + let interceptors = self.interceptorProvider.httpInterceptors(for: graphQLRequest) + + // Setup next function to traverse interceptors + var next: @Sendable (URLRequest) async throws -> HTTPResponse = { request in + return try await executeNetworkFetch(request: request) + } + + for interceptor in interceptors.reversed() { + let tempNext = next - continuation.onTermination = { _ in task.cancel() } + next = { request in + try await interceptor.intercept(request: request, next: tempNext) } + } + + // Kickoff first HTTP interceptor + let httpResponse = try await next(graphQLRequest.toURLRequest()) + + let parsingInterceptor = self.interceptorProvider.responseParser(for: graphQLRequest) + + return try await parsingInterceptor.parse( + response: httpResponse, + for: graphQLRequest, + includeCacheRecords: graphQLRequest.writeResultsToCache ) } + private func executeNetworkFetch( + request: URLRequest + ) async throws -> HTTPResponse { + let (chunks, response) = try await urlSession.chunks(for: request) + + guard let response = response as? HTTPURLResponse else { + preconditionFailure() + } + + return HTTPResponse(response: response, chunks: InterceptorResultStream(stream: chunks)) + } + private func writeToCacheIfNecessary( - result: InterceptorResult.ParsedResult, + response: GraphQLResponse, for request: Request ) async throws { - guard let records = result.cacheRecords, - result.result.source == .server, - request.cachePolicy.shouldAttemptCacheWrite + guard request.writeResultsToCache, + response.cacheRecords != nil, + response.result.source == .server else { return } + let cacheInterceptor = self.interceptorProvider.cacheInterceptor(for: request) try await cacheInterceptor.writeCacheData( - cacheRecords: records, - for: request.operation, - with: result.result + to: self.store, + request: request, + response: response ) } } -extension CachePolicy { - fileprivate var shouldAttemptCacheRead: Bool { - switch self { - case .fetchIgnoringCacheCompletely, - .fetchIgnoringCacheData: - return false +// MARK: - FetchBehavior Helpers - case .returnCacheDataAndFetch, - .returnCacheDataDontFetch, - .returnCacheDataElseFetch: - return true - } - } +extension FetchBehavior { - fileprivate var shouldAttemptCacheWrite: Bool { - switch self { - case .fetchIgnoringCacheCompletely, - .returnCacheDataDontFetch: + fileprivate func shouldReadFromCache(hadFailedNetworkFetch: Bool) -> Bool { + switch self.cacheRead { + case .never: return false - - case .fetchIgnoringCacheData, - .returnCacheDataAndFetch, - .returnCacheDataElseFetch: - return true + case .beforeNetworkFetch: + return !hadFailedNetworkFetch + case .onNetworkFailure: + return hadFailedNetworkFetch } } - - func shouldFetchFromNetwork(hadSuccessfulCacheRead: Bool) -> Bool { - switch self { - case .returnCacheDataDontFetch: - return false - case .fetchIgnoringCacheData, - .returnCacheDataAndFetch, - .fetchIgnoringCacheCompletely: + fileprivate func shouldFetchFromNetwork(hadSuccessfulCacheRead: Bool) -> Bool { + switch self.networkFetch { + case .never: + return false + case .always: return true - - case .returnCacheDataElseFetch: + case .onCacheMiss: return !hadSuccessfulCacheRead } } + } diff --git a/apollo-ios/Sources/Apollo/RequestChainNetworkTransport.swift b/apollo-ios/Sources/Apollo/RequestChainNetworkTransport.swift index 6178e3753..9d190dd21 100644 --- a/apollo-ios/Sources/Apollo/RequestChainNetworkTransport.swift +++ b/apollo-ios/Sources/Apollo/RequestChainNetworkTransport.swift @@ -8,8 +8,12 @@ import Foundation /// for each item sent through it. public final class RequestChainNetworkTransport: NetworkTransport, Sendable { + public let urlSession: any ApolloURLSession + /// The interceptor provider to use when constructing a request chain - let interceptorProvider: any InterceptorProvider + public let interceptorProvider: any InterceptorProvider + + public let store: ApolloStore /// The GraphQL endpoint URL to use. public let endpointURL: URL @@ -20,6 +24,9 @@ public final class RequestChainNetworkTransport: NetworkTransport, Sendable { /// Defaults to an empty dictionary. public let additionalHeaders: [String: String] + #warning( + "Should this be moved into Client Config? APQs wont work for anything that doesn't use RequestChain with APQInterceptor." + ) /// 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. @@ -40,12 +47,6 @@ public final class RequestChainNetworkTransport: NetworkTransport, Sendable { /// Defaults to a ``DefaultRequestBodyCreator`` initialized with the default configuration. public let requestBodyCreator: any JSONRequestBodyCreator - /// Any additional HTTP headers that should be added to **every** request, such as an API key or a language setting. - ////// The telemetry metadata about the client. This is used by GraphOS Studio's - /// [client awareness](https://www.apollographql.com/docs/graphos/platform/insights/client-segmentation) - /// feature. - public let clientAwarenessMetadata: ClientAwarenessMetadata - /// Designated initializer /// /// - Parameters: @@ -60,47 +61,46 @@ public final class RequestChainNetworkTransport: NetworkTransport, Sendable { /// - 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( + urlSession: any ApolloURLSession, interceptorProvider: any InterceptorProvider, + store: ApolloStore, endpointURL: URL, additionalHeaders: [String: String] = [:], apqConfig: AutoPersistedQueryConfiguration = .init(), requestBodyCreator: any JSONRequestBodyCreator = DefaultRequestBodyCreator(), - useGETForQueries: Bool = false, - clientAwarenessMetadata: ClientAwarenessMetadata = ClientAwarenessMetadata() + useGETForQueries: Bool = false ) { + self.urlSession = urlSession self.interceptorProvider = interceptorProvider + self.store = store self.endpointURL = endpointURL - self.additionalHeaders = additionalHeaders self.apqConfig = apqConfig self.requestBodyCreator = requestBodyCreator self.useGETForQueries = useGETForQueries - self.clientAwarenessMetadata = clientAwarenessMetadata } /// Constructs a GraphQL request for the given operation. /// - /// Override this method if you need to use a custom subclass of `HTTPRequest`. - /// /// - Parameters: /// - operation: The operation to create the request for - /// - cachePolicy: The `CachePolicy` to use when creating the request + /// - cachePolicy: The `CachePolicy` to use when creating the request /// - context: [optional] A context that is being passed through the request chain. Should default to `nil`. /// - Returns: The constructed request. public func constructRequest( for operation: Operation, - cachePolicy: CachePolicy, - context: (any RequestContext)? = nil + fetchBehavior: FetchBehavior, + requestConfiguration: RequestConfiguration ) -> JSONRequest { var request = JSONRequest( operation: operation, graphQLEndpoint: self.endpointURL, - cachePolicy: cachePolicy, - context: context, + fetchBehavior: fetchBehavior, + writeResultsToCache: requestConfiguration.writeResultsToCache, + requestTimeout: requestConfiguration.requestTimeout, apqConfig: self.apqConfig, useGETForQueries: self.useGETForQueries, - requestBodyCreator: self.requestBodyCreator, - clientAwarenessMetadata: self.clientAwarenessMetadata + requestBodyCreator: self.requestBodyCreator ) request.addHeaders(self.additionalHeaders) return request @@ -110,13 +110,13 @@ public final class RequestChainNetworkTransport: NetworkTransport, Sendable { public func send( query: Query, - cachePolicy: CachePolicy, - context: (any RequestContext)? = nil + fetchBehavior: FetchBehavior, + requestConfiguration: RequestConfiguration ) throws -> AsyncThrowingStream, any Error> { let request = self.constructRequest( for: query, - cachePolicy: cachePolicy, - context: context + fetchBehavior: fetchBehavior, + requestConfiguration: requestConfiguration ) let chain = makeChain(for: request) @@ -126,31 +126,26 @@ public final class RequestChainNetworkTransport: NetworkTransport, Sendable { public func send( mutation: Mutation, - cachePolicy: CachePolicy, - context: (any RequestContext)? = nil + requestConfiguration: RequestConfiguration ) throws -> AsyncThrowingStream, any Error> { let request = self.constructRequest( for: mutation, - cachePolicy: cachePolicy, - context: context + fetchBehavior: FetchBehavior.NetworkOnly, + requestConfiguration: requestConfiguration ) let chain = makeChain(for: request) - return chain.kickoff(request: request) } 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 RequestChain( + urlSession: urlSession, + interceptorProvider: interceptorProvider, + store: store ) - return chain } } @@ -164,23 +159,19 @@ extension RequestChainNetworkTransport: UploadingNetworkTransport { /// - Parameters: /// - operation: The operation to create a request for /// - files: The files you wish to upload - /// - context: [optional] A context that is being passed through the request chain. Should default to `nil`. - /// - manualBoundary: [optional] A manually set boundary for your upload request. Defaults to nil. + /// - requestConfiguration: A configuration used to configure per-request behaviors for this request /// - Returns: The created request. public func constructUploadRequest( for operation: Operation, - with files: [GraphQLFile], - context: (any RequestContext)? = nil, - manualBoundary: String? = nil + files: [GraphQLFile], + requestConfiguration: RequestConfiguration ) -> UploadRequest { var request = UploadRequest( operation: operation, graphQLEndpoint: self.endpointURL, files: files, - multipartBoundary: manualBoundary, - context: context, - requestBodyCreator: self.requestBodyCreator, - clientAwarenessMetadata: self.clientAwarenessMetadata + writeResultsToCache: requestConfiguration.writeResultsToCache, + requestBodyCreator: self.requestBodyCreator ) request.addHeaders(self.additionalHeaders) return request @@ -189,9 +180,13 @@ extension RequestChainNetworkTransport: UploadingNetworkTransport { public func upload( operation: Operation, files: [GraphQLFile], - context: (any RequestContext)? + requestConfiguration: RequestConfiguration ) throws -> AsyncThrowingStream, any Error> { - let request = self.constructUploadRequest(for: operation, with: files, context: context) + let request = self.constructUploadRequest( + for: operation, + files: files, + requestConfiguration: requestConfiguration + ) let chain = makeChain(for: request) return chain.kickoff(request: request) } diff --git a/apollo-ios/Sources/Apollo/RequestContext.swift b/apollo-ios/Sources/Apollo/RequestContext.swift index 1f0ad2f33..a046db2f8 100644 --- a/apollo-ios/Sources/Apollo/RequestContext.swift +++ b/apollo-ios/Sources/Apollo/RequestContext.swift @@ -9,6 +9,7 @@ import ApolloAPI /// /// This allows the various interceptors to make modifications, or perform actions, with information /// that they cannot get just from the existing operation. It can be anything that conforms to this protocol. +@available(*, deprecated, message: "Use custom @TaskLocal values instead") public protocol RequestContext: Sendable {} /// A request context specialization protocol that specifies options for configuring the timeout of a `URLRequest`. @@ -16,6 +17,7 @@ public protocol RequestContext: Sendable {} /// A `RequestContext` object can conform to this protocol to provide a custom `requestTimeout` for an individual /// request. If the `RequestContext` for a request does not conform to this protocol, the default request timeout /// of `URLRequest` will be used. +@available(*, deprecated, message: "Use RequestConfiguration.requestTimeout instead") public protocol RequestContextTimeoutConfigurable: RequestContext { /// The timeout interval specifies the limit on the idle interval allotted to a request in the process of /// loading. This timeout interval is measured in seconds. diff --git a/apollo-ios/Sources/Apollo/ResponseParsing/IncrementalResponseExecutionHandler.swift b/apollo-ios/Sources/Apollo/ResponseParsing/IncrementalResponseExecutionHandler.swift index 9989c44f1..57f95e075 100644 --- a/apollo-ios/Sources/Apollo/ResponseParsing/IncrementalResponseExecutionHandler.swift +++ b/apollo-ios/Sources/Apollo/ResponseParsing/IncrementalResponseExecutionHandler.swift @@ -1,7 +1,7 @@ import Foundation #if !COCOAPODS - @_spi(Internal) import ApolloAPI +@_spi(Internal) import ApolloAPI #endif public enum IncrementalResponseError: Error, LocalizedError, Equatable { @@ -28,13 +28,13 @@ public enum IncrementalResponseError: Error, LocalizedError, Equatable { extension JSONResponseParser { /// Represents an incremental GraphQL response received from a server. - struct IncrementalResponseExecutionHandler { + struct IncrementalResponseExecutionHandler { private let base: BaseResponseExecutionHandler init( responseBody: JSONObject, - operationVariables: Operation.Variables? + operationVariables: GraphQLOperation.Variables? ) throws { guard let path = responseBody["path"] as? [JSONValue] else { throw IncrementalResponseError.missingPath @@ -120,13 +120,10 @@ extension JSONResponseParser { 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 { + let fragmentIdentifier = DeferredFragmentIdentifier(label: label, fieldPath: fieldPath) + + guard let deferredResponseFormat = Operation.responseFormat as? IncrementalDeferredResponseFormat, + let selectionSetType = deferredResponseFormat.deferredFragments[fragmentIdentifier] as? (any Deferrable.Type) else { throw IncrementalResponseError.missingDeferredSelectionSetType(label, fieldPath.joined(separator: ".")) } diff --git a/apollo-ios/Sources/Apollo/ResponseParsing/JSONResponseParser.swift b/apollo-ios/Sources/Apollo/ResponseParsing/JSONResponseParser.swift index ed1b4f195..7efab7ffd 100644 --- a/apollo-ios/Sources/Apollo/ResponseParsing/JSONResponseParser.swift +++ b/apollo-ios/Sources/Apollo/ResponseParsing/JSONResponseParser.swift @@ -1,6 +1,7 @@ import Foundation + #if !COCOAPODS -import ApolloAPI + import ApolloAPI #endif public enum JSONResponseParsingError: Swift.Error, LocalizedError { @@ -31,12 +32,10 @@ public enum JSONResponseParsingError: Swift.Error, LocalizedError { } } -public struct JSONResponseParser: Sendable { - - public typealias ParsedResult = (GraphQLResult, RecordSet?) +public struct JSONResponseParser: Sendable { let response: HTTPURLResponse - let operationVariables: Operation.Variables? + let operationVariables: GraphQLOperation.Variables? let multipartHeader: HTTPURLResponse.MultipartHeaderComponents let includeCacheRecords: Bool @@ -51,10 +50,10 @@ public struct JSONResponseParser: Sendable { self.includeCacheRecords = includeCacheRecords } - public func parse( + public func parse( dataChunk: Data, - mergingIncrementalItemsInto existingResult: ParsedResult? - ) async throws -> ParsedResult? { + mergingIncrementalItemsInto existingResult: GraphQLResponse? + ) async throws -> GraphQLResponse? { switch response.isMultipart { case false: return try await parseSingleResponse(data: dataChunk) @@ -96,7 +95,9 @@ public struct JSONResponseParser: Sendable { // MARK: - Single Response Parsing - public func parseSingleResponse(data: Data) async throws -> ParsedResult { + public func parseSingleResponse( + data: Data + ) async throws -> GraphQLResponse { guard let body = try? JSONSerializationFormat.deserialize(data: data) as JSONObject else { @@ -106,8 +107,10 @@ public struct JSONResponseParser: Sendable { return try await parseSingleResponse(body: body) } - public func parseSingleResponse(body: JSONObject) async throws -> ParsedResult { - let executionHandler = SingleResponseExecutionHandler( + public func parseSingleResponse( + body: JSONObject + ) async throws -> GraphQLResponse { + let executionHandler = SingleResponseExecutionHandler( responseBody: body, operationVariables: operationVariables ) @@ -130,16 +133,17 @@ public struct JSONResponseParser: Sendable { } } - private func executeIncrementalResponses( + private func executeIncrementalResponses( merging incrementalItems: [JSONObject], - into existingResult: ParsedResult - ) async throws -> ParsedResult { - var currentResult = existingResult.0 - var currentCacheRecords = existingResult.1 + into existingResult: GraphQLResponse + ) async throws -> GraphQLResponse { + var currentResult = existingResult.result + var currentCacheRecords = existingResult.cacheRecords for item in incrementalItems { let (incrementalResult, incrementalCacheRecords) = try await executeIncrementalItem( - itemBody: item + itemBody: item, + for: Operation.self ) try Task.checkCancellation() @@ -150,13 +154,14 @@ public struct JSONResponseParser: Sendable { } } - return (currentResult, currentCacheRecords) + return GraphQLResponse(result: currentResult, cacheRecords: currentCacheRecords) } - private func executeIncrementalItem( - itemBody: JSONObject + private func executeIncrementalItem( + itemBody: JSONObject, + for operationType: Operation.Type ) async throws -> (IncrementalGraphQLResult, RecordSet?) { - let incrementalExecutionHandler = try IncrementalResponseExecutionHandler( + let incrementalExecutionHandler = try IncrementalResponseExecutionHandler( responseBody: itemBody, operationVariables: operationVariables ) diff --git a/apollo-ios/Sources/Apollo/ResponseParsing/SingleResponseExecutionHandler.swift b/apollo-ios/Sources/Apollo/ResponseParsing/SingleResponseExecutionHandler.swift index f45f55f1e..b26b68516 100644 --- a/apollo-ios/Sources/Apollo/ResponseParsing/SingleResponseExecutionHandler.swift +++ b/apollo-ios/Sources/Apollo/ResponseParsing/SingleResponseExecutionHandler.swift @@ -4,12 +4,12 @@ import ApolloAPI extension JSONResponseParser { - struct SingleResponseExecutionHandler { + struct SingleResponseExecutionHandler { private let base: BaseResponseExecutionHandler init( responseBody: JSONObject, - operationVariables: Operation.Variables? + operationVariables: GraphQLOperation.Variables? ) { self.base = BaseResponseExecutionHandler( responseBody: responseBody, @@ -26,10 +26,10 @@ extension JSONResponseParser { /// - Returns: A `GraphQLResult` and optional `RecordSet`. func execute( includeCacheRecords: Bool - ) async throws -> ParsedResult { + ) async throws -> GraphQLResponse { switch includeCacheRecords { case false: - return (try await parseResultOmittingCacheRecords(), nil) + return GraphQLResponse(result: try await parseResultOmittingCacheRecords(), cacheRecords: nil) case true: return try await parseResultIncludingCacheRecords() @@ -40,7 +40,7 @@ extension JSONResponseParser { /// request and the `RecordSet` can be merged into a local cache. /// /// - Returns: A `GraphQLResult` and a `RecordSet`. - public func parseResultIncludingCacheRecords() async throws -> ParsedResult { + public func parseResultIncludingCacheRecords() async throws -> GraphQLResponse { let accumulator = zip( DataDictMapper(), ResultNormalizerFactory.networkResponseDataNormalizer(), @@ -56,7 +56,7 @@ extension JSONResponseParser { dependentKeys: executionResult?.2 ) - return (result, executionResult?.1) + return GraphQLResponse(result: result, cacheRecords: executionResult?.1) } /// Parses a response into a `GraphQLResult` for use without the cache. This parsing does not diff --git a/apollo-ios/Sources/Apollo/UploadRequest.swift b/apollo-ios/Sources/Apollo/UploadRequest.swift index 9088c632b..bac2afda1 100644 --- a/apollo-ios/Sources/Apollo/UploadRequest.swift +++ b/apollo-ios/Sources/Apollo/UploadRequest.swift @@ -1,6 +1,7 @@ import Foundation + #if !COCOAPODS -import ApolloAPI + import ApolloAPI #endif /// A request class allowing for a multipart-upload request. @@ -15,11 +16,17 @@ public struct UploadRequest: GraphQLRequest { /// 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 + /// The `FetchBehavior` to use for this request. Determines if fetching will include cache/network. + public var fetchBehavior: FetchBehavior + + /// Determines if the results of a network fetch should be written to the local cache. + public var writeResultsToCache: Bool - /// [optional] A context that is being passed through the request chain. - public var context: (any RequestContext)? + /// The timeout interval specifies the limit on the idle interval allotted to a request in the process of + /// loading. This timeout interval is measured in seconds. + /// + /// The value of this property will be set as the `timeoutInterval` on the `URLRequest` created for this GraphQL request. + public var requestTimeout: TimeInterval? public let requestBodyCreator: any JSONRequestBodyCreator @@ -29,11 +36,6 @@ public struct UploadRequest: GraphQLRequest { public let serializationFormat = JSONSerializationFormat.self - /// The telemetry metadata about the client. This is used by GraphOS Studio's - /// [client awareness](https://www.apollographql.com/docs/graphos/platform/insights/client-segmentation) - /// feature. - public var clientAwarenessMetadata: ClientAwarenessMetadata - /// Designated Initializer /// /// - Parameters: @@ -48,30 +50,27 @@ public struct UploadRequest: GraphQLRequest { graphQLEndpoint: URL, files: [GraphQLFile], multipartBoundary: String? = nil, - context: (any RequestContext)? = nil, - requestBodyCreator: any JSONRequestBodyCreator = DefaultRequestBodyCreator(), - clientAwarenessMetadata: ClientAwarenessMetadata = ClientAwarenessMetadata() + writeResultsToCache: Bool, + requestBodyCreator: any JSONRequestBodyCreator = DefaultRequestBodyCreator() ) { self.operation = operation self.graphQLEndpoint = graphQLEndpoint - self.cachePolicy = .default - self.context = context self.requestBodyCreator = requestBodyCreator self.files = files self.multipartBoundary = multipartBoundary ?? "apollo-ios.boundary.\(UUID().uuidString)" - self.clientAwarenessMetadata = clientAwarenessMetadata - + self.fetchBehavior = FetchBehavior.NetworkOnly + self.writeResultsToCache = writeResultsToCache self.addHeader(name: "Content-Type", value: "multipart/form-data; boundary=\(self.multipartBoundary)") } - + public func toURLRequest() throws -> URLRequest { let formData = try self.requestMultipartFormData() var request = createDefaultRequest() request.httpBody = try formData.encode() - + return request } - + /// Creates the `MultipartFormData` object to use when creating the URL Request. /// /// This method follows the [GraphQL Multipart Request Spec](https://github.com/jaydenseric/graphql-multipart-request-spec) Override this method to use a different upload spec. @@ -84,20 +83,23 @@ public struct UploadRequest: GraphQLRequest { // 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: self, - sendQueryDocument: true, - autoPersistQuery: false) + var fields = self.requestBodyCreator.requestBody( + for: self.operation, + sendQueryDocument: true, + autoPersistQuery: false + ) var variables = fields["variables"] as? JSONEncodableDictionary ?? JSONEncodableDictionary() for fieldName in fieldsForFiles { if let value = variables[fieldName], - let arrayValue = value as? [any JSONEncodable] { + let arrayValue = value as? [any JSONEncodable] + { let arrayOfNils: [NSNull?] = arrayValue.map { _ in NSNull() } variables.updateValue(arrayOfNils, forKey: fieldName) } else { variables.updateValue(NSNull(), forKey: fieldName) } } - fields["variables"] = variables + fields["variables"] = variables let operationData = try JSONSerializationFormat.serialize(value: fields) formData.appendPart(data: operationData, name: "operations") @@ -105,7 +107,7 @@ public struct UploadRequest: GraphQLRequest { // 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. var map = [String: [String]]() var currentIndex = 0 - + var sortedFiles = [GraphQLFile]() for fieldName in fieldsForFiles { let filesForField = files.filter { $0.fieldName == fieldName } @@ -122,18 +124,23 @@ public struct UploadRequest: GraphQLRequest { } } } - - assert(sortedFiles.count == files.count, "Number of sorted files did not equal the number of incoming files - some field name has been left out.") + + 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 JSONSerializationFormat.serialize(value: map) formData.appendPart(data: mapData, name: "map") for (index, file) in sortedFiles.enumerated() { - formData.appendPart(inputStream: try file.generateInputStream(), - contentLength: file.contentLength, - name: "\(index)", - contentType: file.mimeType, - filename: file.originalName) + formData.appendPart( + inputStream: try file.generateInputStream(), + contentLength: file.contentLength, + name: "\(index)", + contentType: file.mimeType, + filename: file.originalName + ) } return formData @@ -142,20 +149,24 @@ public struct UploadRequest: GraphQLRequest { // MARK: - Equtable/Hashable Conformance public static func == (lhs: UploadRequest, rhs: UploadRequest) -> Bool { - 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.multipartBoundary == rhs.multipartBoundary + lhs.graphQLEndpoint == rhs.graphQLEndpoint + && lhs.operation == rhs.operation + && lhs.additionalHeaders == rhs.additionalHeaders + && lhs.fetchBehavior == rhs.fetchBehavior + && lhs.writeResultsToCache == rhs.writeResultsToCache + && lhs.requestTimeout == rhs.requestTimeout + && type(of: lhs.requestBodyCreator) == type(of: rhs.requestBodyCreator) + && lhs.files == rhs.files + && lhs.multipartBoundary == rhs.multipartBoundary } public func hash(into hasher: inout Hasher) { hasher.combine(graphQLEndpoint) hasher.combine(operation) hasher.combine(additionalHeaders) - hasher.combine(cachePolicy) + hasher.combine(fetchBehavior) + hasher.combine(writeResultsToCache) + hasher.combine(requestTimeout) hasher.combine("\(type(of: requestBodyCreator))") hasher.combine(files) hasher.combine(multipartBoundary) diff --git a/apollo-ios/Sources/ApolloAPI/GraphQLOperation.swift b/apollo-ios/Sources/ApolloAPI/GraphQLOperation.swift index deda7106b..842ae957c 100644 --- a/apollo-ios/Sources/ApolloAPI/GraphQLOperation.swift +++ b/apollo-ios/Sources/ApolloAPI/GraphQLOperation.swift @@ -74,33 +74,17 @@ public protocol GraphQLOperation: Sendable, Hashable { static var operationName: String { get } static var operationType: GraphQLOperationType { get } static var operationDocument: OperationDocument { get } - - static var deferredFragments: [DeferredFragmentIdentifier: any SelectionSet.Type]? { get } + static var responseFormat: ResponseFormat { get } var __variables: Variables? { get } associatedtype Data: RootSelectionSet + associatedtype ResponseFormat: OperationResponseFormat = SingleResponseFormat } // MARK: Static Extensions public extension GraphQLOperation { - static var deferredFragments: [DeferredFragmentIdentifier: any SelectionSet.Type]? { - return nil - } - - @inlinable - static func deferredSelectionSetType( - withLabel label: String, - atFieldPath fieldPath: [String] - ) -> (any SelectionSet.Type)? { - return Self.deferredFragments?[DeferredFragmentIdentifier(label: label, fieldPath: fieldPath)] - } - - static var hasDeferredFragments: Bool { - return !(deferredFragments?.isEmpty ?? true) - } - static var definition: OperationDefinition? { operationDocument.definition } @@ -120,6 +104,10 @@ public extension GraphQLOperation { } } +public extension GraphQLOperation where ResponseFormat == SingleResponseFormat { + static var responseFormat: ResponseFormat { SingleResponseFormat() } +} + // MARK: Instance Extensions public extension GraphQLOperation { @@ -150,11 +138,35 @@ public extension GraphQLMutation { // MARK: - GraphQLSubscription -public protocol GraphQLSubscription: GraphQLOperation {} +public protocol GraphQLSubscription: GraphQLOperation { + associatedtype ResponseFormat: OperationResponseFormat = SubscriptionResponseFormat +} + public extension GraphQLSubscription { @inlinable static var operationType: GraphQLOperationType { return .subscription } } +// MARK: - OperationResponseFormat + +/// The format expected for the network response of an operation. +public protocol OperationResponseFormat {} + +/// A response format for an operation that expects a single network response. +/// +/// This is the default format for most operations. +public struct SingleResponseFormat: OperationResponseFormat {} + +/// An incremental response format for an operation which uses the `@defer` directive. +public struct IncrementalDeferredResponseFormat: OperationResponseFormat { + public let deferredFragments: [DeferredFragmentIdentifier: any SelectionSet.Type] + + public init(deferredFragments: [DeferredFragmentIdentifier: any SelectionSet.Type]) { + self.deferredFragments = deferredFragments + } +} + +public struct SubscriptionResponseFormat: OperationResponseFormat {} + // MARK: - GraphQLOperationVariableValue public protocol GraphQLOperationVariableValue: Sendable { diff --git a/apollo-ios/Sources/ApolloWebSocket/SplitNetworkTransport.swift b/apollo-ios/Sources/ApolloWebSocket/SplitNetworkTransport.swift index 24753db4f..ef01c9457 100644 --- a/apollo-ios/Sources/ApolloWebSocket/SplitNetworkTransport.swift +++ b/apollo-ios/Sources/ApolloWebSocket/SplitNetworkTransport.swift @@ -5,16 +5,20 @@ import Foundation import ApolloAPI #endif -#warning( - """ - TODO: This is messy. Why is http network transport called "uploadingNetworkTransport"? - Websocket transport should be typesafe to a protocol that guaruntees it supports web sockets/ subscriptions - """ -) -/// A network transport that sends subscriptions using one `NetworkTransport` and other requests using another `NetworkTransport`. Ideal for sending subscriptions via a web socket but everything else via HTTP. -public final class SplitNetworkTransport: Sendable { - private let uploadingNetworkTransport: any UploadingNetworkTransport - private let webSocketNetworkTransport: any SubscriptionNetworkTransport +/// A network transport that sends allows you to use different `NetworkTransport` types for each operation type. +/// +/// This can be used, for example, to send subscriptions via a web socket transport but everything else via HTTP. +public final class SplitNetworkTransport< + QueryTransport: NetworkTransport, + MutationTransport: NetworkTransport, + SubscriptionTransport: Sendable, + UploadTransport: Sendable +>: NetworkTransport, Sendable { + + private let queryTransport: QueryTransport + private let mutationTransport: MutationTransport + private let subscriptionTransport: SubscriptionTransport + private let uploadTransport: UploadTransport /// Designated initializer /// @@ -22,72 +26,73 @@ public final class SplitNetworkTransport: Sendable { /// - uploadingNetworkTransport: An `UploadingNetworkTransport` to use for non-subscription requests. Should generally be a `RequestChainNetworkTransport` or something similar. /// - webSocketNetworkTransport: A `NetworkTransport` to use for subscription requests. Should generally be a `WebSocketTransport` or something similar. public init( - uploadingNetworkTransport: any UploadingNetworkTransport, - webSocketNetworkTransport: any SubscriptionNetworkTransport + queryTransport: QueryTransport, + mutationTransport: MutationTransport, + subscriptionTransport: SubscriptionTransport = Void(), + uploadTransport: UploadTransport = Void(), ) { - self.uploadingNetworkTransport = uploadingNetworkTransport - self.webSocketNetworkTransport = webSocketNetworkTransport + self.queryTransport = queryTransport + self.mutationTransport = mutationTransport + self.subscriptionTransport = subscriptionTransport + self.uploadTransport = uploadTransport } -} -// MARK: - NetworkTransport conformance + // MARK: - NetworkTransport conformance -extension SplitNetworkTransport: NetworkTransport { - - public func send( + public func send( query: Query, - cachePolicy: CachePolicy, - context: (any RequestContext)? - ) throws -> AsyncThrowingStream, any Error> where Query: GraphQLQuery { - return try uploadingNetworkTransport.send( + fetchBehavior: FetchBehavior, + requestConfiguration: RequestConfiguration + ) throws -> AsyncThrowingStream, any Error> { + return try queryTransport.send( query: query, - cachePolicy: cachePolicy, - context: context + fetchBehavior: fetchBehavior, + requestConfiguration: requestConfiguration ) } - public func send( + public func send( mutation: Mutation, - cachePolicy: CachePolicy, - context: (any RequestContext)? - ) throws -> AsyncThrowingStream, any Error> where Mutation: GraphQLMutation { - return try uploadingNetworkTransport.send( + requestConfiguration: RequestConfiguration + ) throws -> AsyncThrowingStream, any Error> { + return try mutationTransport.send( mutation: mutation, - cachePolicy: cachePolicy, - context: context + requestConfiguration: requestConfiguration ) } } // MARK: - SubscriptionNetworkTransport conformance -extension SplitNetworkTransport: SubscriptionNetworkTransport { - public func send( +extension SplitNetworkTransport: SubscriptionNetworkTransport +where SubscriptionTransport: SubscriptionNetworkTransport { + + public func send( subscription: Subscription, - cachePolicy: CachePolicy, - context: (any RequestContext)? - ) throws -> AsyncThrowingStream, any Error> where Subscription: GraphQLSubscription { - return try webSocketNetworkTransport.send( + fetchBehavior: FetchBehavior, + requestConfiguration: RequestConfiguration + ) throws -> AsyncThrowingStream, any Error> { + return try subscriptionTransport.send( subscription: subscription, - cachePolicy: cachePolicy, - context: context + fetchBehavior: fetchBehavior, + requestConfiguration: requestConfiguration ) } } // MARK: - UploadingNetworkTransport conformance -extension SplitNetworkTransport: UploadingNetworkTransport { +extension SplitNetworkTransport: UploadingNetworkTransport where UploadTransport: UploadingNetworkTransport { public func upload( operation: Operation, files: [GraphQLFile], - context: (any RequestContext)? + requestConfiguration: RequestConfiguration ) throws -> AsyncThrowingStream, any Error> { - return try uploadingNetworkTransport.upload( + return try uploadTransport.upload( operation: operation, files: files, - context: context + requestConfiguration: requestConfiguration ) } } diff --git a/apollo-ios/Sources/ApolloWebSocket/WebSocketTransport.swift b/apollo-ios/Sources/ApolloWebSocket/WebSocketTransport.swift index 540dd9f55..1bb73c606 100644 --- a/apollo-ios/Sources/ApolloWebSocket/WebSocketTransport.swift +++ b/apollo-ios/Sources/ApolloWebSocket/WebSocketTransport.swift @@ -433,32 +433,28 @@ extension URLRequest { extension WebSocketTransport: SubscriptionNetworkTransport { public func send( query: Query, - cachePolicy: CachePolicy, - context: (any RequestContext)? + cachePolicy: CachePolicy ) throws -> AsyncThrowingStream, any Error> { - try send(operation: query, cachePolicy: cachePolicy, context: context) + try send(operation: query, cachePolicy: cachePolicy) } public func send( mutation: Mutation, - cachePolicy: CachePolicy, - context: (any RequestContext)? + cachePolicy: CachePolicy ) throws -> AsyncThrowingStream, any Error> { - try send(operation: mutation, cachePolicy: cachePolicy, context: context) + try send(operation: mutation, cachePolicy: cachePolicy) } public func send( subscription: Subscription, - cachePolicy: CachePolicy, - context: (any RequestContext)? + cachePolicy: CachePolicy ) throws -> AsyncThrowingStream, any Error> { - try send(operation: subscription, cachePolicy: cachePolicy, context: context) + try send(operation: subscription, cachePolicy: cachePolicy) } private func send( operation: Operation, - cachePolicy: CachePolicy, - context: (any RequestContext)? = nil + cachePolicy: CachePolicy ) throws -> AsyncThrowingStream, any Error> { if let error = self.error { return AsyncThrowingStream.init {