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/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/ApolloClient.swift b/apollo-ios/Sources/Apollo/ApolloClient.swift index b7c327df9..fd5b109d1 100644 --- a/apollo-ios/Sources/Apollo/ApolloClient.swift +++ b/apollo-ios/Sources/Apollo/ApolloClient.swift @@ -5,43 +5,20 @@ import Foundation 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. Only valid for queries currently") -public enum CachePolicy: Sendable, Hashable { - /// Return data from the cache if available, else fetch results from the server. - case cacheFirst - /// Attempt to fetch results from the server, if failed, return data from the cache if available. - case networkFirst - /// Fetch results from the server, do not attempt to read data from the cache. - case networkOnly - /// Return data from the cache if available, and always fetch results from the server. - case cacheAndNetwork // seperate function? - - /// Return data from the cache if available, else return an error. - case cacheOnly // replace with separate function? - - /// Always fetch results from the server, and don't store these in the cache. - // case fetchIgnoringCacheCompletely - - #warning("TODO: this unsafe is not properly made atomic. Fix this") - // /// The current default cache policy. - // nonisolated(unsafe) public static var `default`: CachePolicy = .cacheFirst -} +public struct RequestConfiguration: Sendable { + public var requestTimeout: TimeInterval? + public var writeResultsToCache: Bool -/// 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 - -struct RequestConfigurationContext { - @TaskLocal static var taskLocal: RequestConfigurationContext = .init() - - var requestTimeout: Int32 = 30 - var writeResultsToCache: Bool = true + public init( + requestTimeout: TimeInterval? = nil, + writeResultsToCache: Bool = true + ) { + self.requestTimeout = requestTimeout + self.writeResultsToCache = writeResultsToCache + } } +// MARK: - /// The `ApolloClient` class implements the core API for Apollo by conforming to `ApolloClientProtocol`. public final class ApolloClient: ApolloClientProtocol, Sendable { @@ -49,8 +26,7 @@ public final class ApolloClient: ApolloClientProtocol, Sendable { public let store: ApolloStore - #warning("TODO: unit test usage") - public let defaultCachePolicy: CachePolicy + public let defaultRequestConfiguration: RequestConfiguration public enum ApolloClientError: Error, LocalizedError, Hashable { case noResults @@ -76,54 +52,84 @@ public final class ApolloClient: ApolloClientProtocol, 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. - /// - defaultCachePolicy: TODO + /// - 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, - defaultCachePolicy: CachePolicy = .cacheFirst + defaultRequestConfiguration: RequestConfiguration = RequestConfiguration(), + clientAwarenessMetadata: ClientAwarenessMetadata = ClientAwarenessMetadata() ) { self.networkTransport = networkTransport self.store = store - self.defaultCachePolicy = defaultCachePolicy + 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, - defaultCachePolicy: CachePolicy = .cacheFirst, + defaultRequestConfiguration: RequestConfiguration = RequestConfiguration(), clientAwarenessMetadata: ClientAwarenessMetadata = ClientAwarenessMetadata() ) { let store = ApolloStore(cache: InMemoryNormalizedCache()) 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, - defaultCachePolicy: defaultCachePolicy + 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? = nil, - context: (any RequestContext)? = nil + cachePolicy: CachePolicy.Query.SingleResponse, + requestConfiguration: RequestConfiguration? = nil ) async throws -> GraphQLResult where Query.ResponseFormat == SingleResponseFormat { - for try await result in try self.networkTransport.send( + for try await result in try fetch( query: query, - cachePolicy: cachePolicy ?? self.defaultCachePolicy, - context: context + fetchBehavior: cachePolicy.toFetchBehavior(), + requestConfiguration: requestConfiguration ) { return result } @@ -132,132 +138,411 @@ public final class ApolloClient: ApolloClientProtocol, Sendable { public func fetch( query: Query, - cachePolicy: CachePolicy? = nil, - context: (any RequestContext)? = nil + 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 self.networkTransport.send( + return try fetch( query: query, - cachePolicy: cachePolicy ?? self.defaultCachePolicy, - context: context + fetchBehavior: cachePolicy.toFetchBehavior(), + requestConfiguration: requestConfiguration ) } - /// 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. + 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 + ) + } + + // 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. - /// - cachePolicy: A cache policy that specifies when results should be fetched from the server or from the local cache. + /// - 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. 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. + /// 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? = nil, + fetchBehavior: FetchBehavior = FetchBehavior.CacheElseNetwork, + requestConfiguration: RequestConfiguration? = nil, refetchOnFailedUpdates: Bool = true, - context: (any RequestContext)? = nil, - callbackQueue: DispatchQueue = .main, - resultHandler: @escaping GraphQLResultHandler + resultHandler: @escaping GraphQLQueryWatcher.ResultHandler ) -> GraphQLQueryWatcher { let watcher = GraphQLQueryWatcher( client: self, query: query, refetchOnFailedUpdates: refetchOnFailedUpdates, - context: context, - callbackQueue: callbackQueue, resultHandler: resultHandler ) - watcher.fetch(cachePolicy: cachePolicy ?? self.defaultCachePolicy) + Task { + await watcher.fetch(fetchBehavior: fetchBehavior, requestConfiguration: requestConfiguration) + } return watcher } - @discardableResult + // 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, - publishResultToStore: Bool = true, - context: (any RequestContext)? = nil, - queue: DispatchQueue = .main, - resultHandler: GraphQLResultHandler? = nil - ) -> (any Cancellable) { - return awaitStreamInTask( - { - try self.networkTransport.send( - mutation: mutation, - cachePolicy: publishResultToStore ? self.defaultCachePolicy : .networkOnly, // TODO: should be NoCache - context: context - ) - }, - callbackQueue: queue, - completion: resultHandler - ) + 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 } - @discardableResult + /// 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], - context: (any RequestContext)? = nil, - queue: DispatchQueue = .main, - resultHandler: GraphQLResultHandler? = nil - ) -> (any Cancellable) { + 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`." ) - queue.async { - resultHandler?(.failure(ApolloClientError.noUploadTransport)) - } - return EmptyCancellable() + throw ApolloClientError.noUploadTransport } - return awaitStreamInTask( - { - try uploadingTransport.upload( - operation: operation, - files: files, - context: context - ) - }, - callbackQueue: queue, - completion: resultHandler - ) + 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, - context: (any RequestContext)? = nil, - queue: DispatchQueue = .main, - resultHandler: @escaping GraphQLResultHandler - ) -> any Cancellable { - guard let networkTransport = networkTransport as? (any SubscriptionNetworkTransport) else { + 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`." ) - queue.async { - resultHandler(.failure(ApolloClientError.noSubscriptionTransport)) - } - return EmptyCancellable() + throw ApolloClientError.noSubscriptionTransport } - return awaitStreamInTask( - { - try networkTransport.send( + return try doInClientContext { + return try subscriptionTransport + .send( subscription: subscription, - cachePolicy: self.defaultCachePolicy, // TODO: should this just be networkOnly? - context: context + fetchBehavior: cachePolicy.toFetchBehavior(), + requestConfiguration: requestConfiguration ?? defaultRequestConfiguration ) - }, - callbackQueue: queue, - completion: resultHandler - ) + } + } + + // 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) @@ -266,22 +551,24 @@ extension ApolloClient { completion: (@Sendable (Result) -> Void)? = nil ) { self.store.clearCache(callbackQueue: callbackQueue, completion: completion) - } + } + @_disfavoredOverload @available(*, deprecated) @discardableResult public func fetch( query: Query, - cachePolicy: CachePolicy? = nil, + cachePolicy: CachePolicy_v1? = nil, context: (any RequestContext)? = nil, queue: DispatchQueue = .main, resultHandler: GraphQLResultHandler? = nil ) -> (any Cancellable) { + let cachePolicy = cachePolicy ?? CachePolicy_v1.default return awaitStreamInTask( { - try self.networkTransport.send( + try self.fetch( query: query, - cachePolicy: cachePolicy ?? self.defaultCachePolicy, - context: context + fetchBehavior: cachePolicy.toFetchBehavior(), + requestConfiguration: RequestConfiguration(writeResultsToCache: cachePolicy != .fetchIgnoringCacheCompletely) ) }, callbackQueue: queue, @@ -326,20 +613,100 @@ extension ApolloClient { ) public func watch( query: Query, - cachePolicy: CachePolicy? = nil, + cachePolicy: CachePolicy_v1? = nil, context: (any RequestContext)? = nil, callbackQueue: DispatchQueue = .main, resultHandler: @escaping GraphQLResultHandler ) -> GraphQLQueryWatcher { - let watcher = GraphQLQueryWatcher( - client: self, + let cachePolicy = cachePolicy ?? CachePolicy_v1.default + let config = RequestConfiguration( + requestTimeout: defaultRequestConfiguration.requestTimeout, + writeResultsToCache: cachePolicy == .fetchIgnoringCacheCompletely + ? false : defaultRequestConfiguration.writeResultsToCache + ) + return self.watch( query: query, - context: context, - callbackQueue: callbackQueue, + fetchBehavior: cachePolicy.toFetchBehavior(), + requestConfiguration: config, resultHandler: resultHandler ) - watcher.fetch(cachePolicy: cachePolicy ?? self.defaultCachePolicy) - return watcher + } + + @discardableResult + @_disfavoredOverload + @available( + *, + deprecated, + renamed: "perform(mutation:requestConfiguration:)" + ) + public func perform( + mutation: Mutation, + publishResultToStore: Bool = true, + queue: DispatchQueue = .main, + resultHandler: GraphQLResultHandler? = nil + ) -> (any Cancellable) { + let config = RequestConfiguration( + requestTimeout: defaultRequestConfiguration.requestTimeout, + writeResultsToCache: publishResultToStore + ) + + return awaitStreamInTask( + { + try self.networkTransport.send( + mutation: mutation, + requestConfiguration: config + ) + }, + callbackQueue: queue, + completion: resultHandler + ) + } + + @discardableResult + @_disfavoredOverload + @available( + *, + deprecated, + renamed: "upload(operation:files:requestConfiguration:)" + ) + public func upload( + operation: Operation, + files: [GraphQLFile], + queue: DispatchQueue = .main, + resultHandler: GraphQLResultHandler? = nil + ) -> (any Cancellable) { + return awaitStreamInTask( + { + try self.sendUpload( + operation: operation, + files: files, + requestConfiguration: nil + ) + }, + callbackQueue: queue, + completion: resultHandler + ) + } + + @discardableResult + @_disfavoredOverload + @available( + *, + deprecated, + renamed: "subscribe(subscription:cachePolicy:requestConfiguration:)" + ) + public func subscribe( + subscription: Subscription, + queue: DispatchQueue = .main, + resultHandler: @escaping GraphQLResultHandler + ) -> any Cancellable { + return awaitStreamInTask( + { + try await self.subscribe(subscription: subscription) + }, + callbackQueue: queue, + completion: resultHandler + ) } } diff --git a/apollo-ios/Sources/Apollo/ApolloClientProtocol.swift b/apollo-ios/Sources/Apollo/ApolloClientProtocol.swift index ab6555b42..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 644b41ed0..24fca19f4 100644 --- a/apollo-ios/Sources/Apollo/ApolloStore.swift +++ b/apollo-ios/Sources/Apollo/ApolloStore.swift @@ -31,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: @@ -42,7 +42,7 @@ public final class ApolloStore: Sendable { } fileprivate func didChangeKeys(_ changedKeys: Set) { - for subscriber in self.subscribers { + for subscriber in self.subscribers.values { subscriber.store(self, didChangeKeys: changedKeys) } } @@ -69,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) } } } @@ -118,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. @@ -146,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/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 index cfc049676..fc45a030d 100644 --- a/apollo-ios/Sources/Apollo/FetchBehavior.swift +++ b/apollo-ios/Sources/Apollo/FetchBehavior.swift @@ -1,35 +1,69 @@ -public struct FetchBehavior: Sendable, Hashable { +// 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 + ) - public var shouldAttemptCacheRead: Bool + /// Return data from the cache if available, and always fetch results from the server. + public static let CacheThenNetwork = FetchBehavior( + cacheRead: .beforeNetworkFetch, + networkFetch: .always + ) - public var shouldAttemptCacheWrite: Bool + /// 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 + ) - public var shouldAttemptNetworkFetch: NetworkBehavior + /// 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 + ) - public init( - shouldAttemptCacheRead: Bool, - shouldAttemptCacheWrite: Bool, - shouldAttemptNetworkFetch: NetworkBehavior - ) { - self.shouldAttemptCacheRead = shouldAttemptCacheRead - self.shouldAttemptCacheWrite = shouldAttemptCacheWrite - self.shouldAttemptNetworkFetch = shouldAttemptNetworkFetch + /// 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 NetworkBehavior: Sendable { + public enum NetworkFetchBehavior: Sendable { case never case always - case onCacheFailure + case onCacheMiss } - public func shouldFetchFromNetwork(hadSuccessfulCacheRead: Bool) -> Bool { - switch self.shouldAttemptNetworkFetch { - case .never: - return false - case .always: - return true - case .onCacheFailure: - return !hadSuccessfulCacheRead - } + 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 9e038f102..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 `FetchBehavior` to use for this request. Determines if fetching will include cache/network. + /// 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/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/ApolloInterceptor.swift b/apollo-ios/Sources/Apollo/Interceptors/ApolloInterceptor.swift index 2e44b7601..4a5c21f4c 100644 --- a/apollo-ios/Sources/Apollo/Interceptors/ApolloInterceptor.swift +++ b/apollo-ios/Sources/Apollo/Interceptors/ApolloInterceptor.swift @@ -59,164 +59,8 @@ public protocol HTTPInterceptor: Sendable { typealias NextHTTPInterceptorFunction = @Sendable (URLRequest) async throws -> HTTPResponse func intercept( - request: URLRequest, - context: (any RequestContext)?, + request: URLRequest, next: NextHTTPInterceptorFunction ) async throws -> HTTPResponse } - -#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 - - init(stream: AsyncThrowingStream) { - self.stream = stream - } - - init(stream wrapped: sending S) where S.Element == T { - self.stream = AsyncThrowingStream { continuation in - let task = Task { [wrapped] in - do { - for try await element in wrapped { - continuation.yield(element) - } - - continuation.finish() - } catch { - continuation.finish(throwing: error) - } - } - - continuation.onTermination = { _ in task.cancel() } - } - } - - public consuming func map( - _ transform: @escaping @Sendable (T) async throws -> T - ) async throws -> InterceptorResultStream { - let stream = self.stream - - let newStream = AsyncThrowingStream { continuation in - let task = Task { - do { - for try await result in stream { - try Task.checkCancellation() - - try await continuation.yield(transform(result)) - } - continuation.finish() - - } catch { - continuation.finish(throwing: error) - } - } - - continuation.onTermination = { _ in task.cancel() } - } - return Self.init(stream: newStream) - } - - public consuming func compactMap( - _ transform: @escaping @Sendable (T) async throws -> T? - ) async throws -> InterceptorResultStream { - let stream = self.stream - - let newStream = AsyncThrowingStream { continuation in - let task = Task { - do { - for try await result in stream { - try Task.checkCancellation() - - guard let newResult = try await transform(result) else { - continue - } - - continuation.yield(newResult) - } - continuation.finish() - } catch { - continuation.finish(throwing: error) - } - } - - continuation.onTermination = { _ in task.cancel() } - } - return Self.init(stream: newStream) - } - - public consuming func getResults() -> AsyncThrowingStream { - 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 { continuation in - let task = Task { - do { - for try await result in stream { - try Task.checkCancellation() - - continuation.yield(result) - } - continuation.finish() - - } catch { - do { - if let recoveryResult = try await transform(error) { - continuation.yield(recoveryResult) - } - continuation.finish() - - } catch { - continuation.finish(throwing: error) - } - } - } - - continuation.onTermination = { _ in task.cancel() } - } - 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/AutomaticPersistedQueryInterceptor.swift b/apollo-ios/Sources/Apollo/Interceptors/AutomaticPersistedQueryInterceptor.swift index 6b5729cce..ebbb78d18 100644 --- a/apollo-ios/Sources/Apollo/Interceptors/AutomaticPersistedQueryInterceptor.swift +++ b/apollo-ios/Sources/Apollo/Interceptors/AutomaticPersistedQueryInterceptor.swift @@ -46,8 +46,9 @@ public struct AutomaticPersistedQueryInterceptor: ApolloInterceptor { let isInitialResult = IsInitialResult() return try await next(request).map { response in - - guard await isInitialResult.get() else { +#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 } @@ -76,6 +77,8 @@ public struct AutomaticPersistedQueryInterceptor: ApolloInterceptor { var jsonRequest = jsonRequest // We need to retry this query with the full body. jsonRequest.isPersistedQueryRetry = true + 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 f39178f61..22c0024cd 100644 --- a/apollo-ios/Sources/Apollo/Interceptors/CacheInterceptor.swift +++ b/apollo-ios/Sources/Apollo/Interceptors/CacheInterceptor.swift @@ -7,7 +7,7 @@ public protocol CacheInterceptor: Sendable { func readCacheData( from store: ApolloStore, request: Request - ) async throws -> GraphQLResult where Request.Operation: GraphQLQuery + ) async throws -> GraphQLResult? func writeCacheData( to store: ApolloStore, @@ -24,7 +24,7 @@ public struct DefaultCacheInterceptor: CacheInterceptor { public func readCacheData( from store: ApolloStore, request: Request - ) async throws -> GraphQLResult where Request.Operation: GraphQLQuery { + ) async throws -> GraphQLResult? { return try await store.load(request.operation) } diff --git a/apollo-ios/Sources/Apollo/Interceptors/ResponseCodeInterceptor.swift b/apollo-ios/Sources/Apollo/Interceptors/ResponseCodeInterceptor.swift index 21c279e1d..da0cf9321 100644 --- a/apollo-ios/Sources/Apollo/Interceptors/ResponseCodeInterceptor.swift +++ b/apollo-ios/Sources/Apollo/Interceptors/ResponseCodeInterceptor.swift @@ -33,7 +33,6 @@ public struct ResponseCodeInterceptor: HTTPInterceptor { public func intercept( request: URLRequest, - context: (any RequestContext)?, next: NextHTTPInterceptorFunction ) async throws -> HTTPResponse { return try await next(request).mapChunks { (response, chunk) in diff --git a/apollo-ios/Sources/Apollo/JSONRequest.swift b/apollo-ios/Sources/Apollo/JSONRequest.swift index 67a76221e..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. @@ -18,9 +19,15 @@ public struct JSONRequest: GraphQLRequest, AutoPers /// The `FetchBehavior` to use for this request. Determines if fetching will include cache/network. public var fetchBehavior: FetchBehavior - /// [optional] A context that is being passed through the request chain. - public var context: (any RequestContext)? - + /// 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? + 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. @@ -58,38 +59,40 @@ public struct JSONRequest: GraphQLRequest, AutoPers operation: Operation, graphQLEndpoint: URL, fetchBehavior: FetchBehavior, - context: (any RequestContext)? = nil, + 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.fetchBehavior = fetchBehavior - 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,50 +166,32 @@ public struct JSONRequest: GraphQLRequest, AutoPers sendQueryDocument = true autoPersistQueries = false } - + let body = self.requestBodyCreator.requestBody( - for: self, + 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 { - if !fetchBehavior.shouldAttemptCacheRead { - if fetchBehavior.shouldAttemptCacheWrite { - return .reloadIgnoringLocalCacheData - } else { - return .reloadIgnoringLocalAndRemoteCacheData - } - } else { - switch fetchBehavior.shouldAttemptNetworkFetch { - case .always: - return .reloadRevalidatingCacheData - case .never: - return .returnCacheDataDontLoad - case .onCacheFailure: - return .useProtocolCachePolicy - } - } - } - // 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.fetchBehavior == rhs.fetchBehavior && - 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) { @@ -215,6 +199,8 @@ public struct JSONRequest: GraphQLRequest, AutoPers hasher.combine(operation) hasher.combine(additionalHeaders) hasher.combine(fetchBehavior) + hasher.combine(writeResultsToCache) + hasher.combine(requestTimeout) hasher.combine(apqConfig) hasher.combine(isPersistedQueryRetry) hasher.combine(useGETForQueries) @@ -234,6 +220,7 @@ extension JSONRequest: CustomDebugStringConvertible { } debugStrings.append("]") 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 976085b8f..cadef93ed 100644 --- a/apollo-ios/Sources/Apollo/NetworkTransport.swift +++ b/apollo-ios/Sources/Apollo/NetworkTransport.swift @@ -10,19 +10,19 @@ public protocol NetworkTransport: AnyObject, Sendable { /// /// - 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> } @@ -33,8 +33,8 @@ public protocol SubscriptionNetworkTransport: NetworkTransport { func send( subscription: Subscription, - cachePolicy: CachePolicy, - context: (any RequestContext)? + fetchBehavior: FetchBehavior, + requestConfiguration: RequestConfiguration ) throws -> AsyncThrowingStream, any Error> } @@ -49,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/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 24a0ec685..77f09dfe9 100644 --- a/apollo-ios/Sources/Apollo/RequestChain.swift +++ b/apollo-ios/Sources/Apollo/RequestChain.swift @@ -20,27 +20,25 @@ public enum RequestChainError: Swift.Error, LocalizedError { 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 + self.request = request } } - + private let urlSession: any ApolloURLSession 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, interceptorProvider: any InterceptorProvider, store: ApolloStore @@ -48,29 +46,21 @@ public struct RequestChain: Sendable { self.urlSession = urlSession 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.fetchBehavior.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) } } @@ -78,22 +68,9 @@ public 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) } } } @@ -110,40 +87,20 @@ public struct RequestChain: Sendable { } } - private func handleCacheRead( - request: Request, - continuation: ResultStream.Continuation - ) async throws -> Bool where Operation: GraphQLQuery { - guard request.fetchBehavior.shouldAttemptCacheRead else { - return false - } - - do { - let cacheInterceptor = self.interceptorProvider.cacheInterceptor(for: request) - let cacheData = try await cacheInterceptor.readCacheData(from: self.store, request: request) - continuation.yield(cacheData) - return true - - } catch { - if !request.fetchBehavior.shouldFetchFromNetwork(hadSuccessfulCacheRead: false) { - 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 finalRequest = request - return try await kickOffHTTPInterceptors(for: request) - } - let interceptors = self.interceptorProvider.graphQLInterceptors(for: initialRequest) + return execute(request: request) + } for interceptor in interceptors.reversed() { let tempNext = next @@ -153,16 +110,17 @@ public struct RequestChain: Sendable { } } + // Kickoff first interceptor let resultStream = try await next(initialRequest) var didEmitResult: Bool = false - for try await result in resultStream.getResults() { + 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 } @@ -171,24 +129,99 @@ public struct RequestChain: Sendable { } } + #warning("TODO: unit tests for cache read after failed network fetch") + private func execute( + request: Request + ) -> InterceptorResultStream> { + return InterceptorResultStream( + stream: AsyncThrowingStream, any Error>.executingInAsyncTask { continuation in + let fetchBehavior = request.fetchBehavior + var didYieldCacheData: Bool = false + + // 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) + ) + } + + // 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 + + } catch { + // 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( - for graphQLRequest: Request + 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) } - let interceptors = self.interceptorProvider.httpInterceptors(for: graphQLRequest) - let context = graphQLRequest.context - for interceptor in interceptors.reversed() { let tempNext = next next = { request in - try await interceptor.intercept(request: request, context: context, next: tempNext) + 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) @@ -196,7 +229,7 @@ public struct RequestChain: Sendable { return try await parsingInterceptor.parse( response: httpResponse, for: graphQLRequest, - includeCacheRecords: graphQLRequest.fetchBehavior.shouldAttemptCacheWrite + includeCacheRecords: graphQLRequest.writeResultsToCache ) } @@ -213,12 +246,12 @@ public struct RequestChain: Sendable { } private func writeToCacheIfNecessary( - result: GraphQLResponse, + response: GraphQLResponse, for request: Request ) async throws { - guard let records = result.cacheRecords, - result.result.source == .server, - request.fetchBehavior.shouldAttemptCacheWrite + guard request.writeResultsToCache, + response.cacheRecords != nil, + response.result.source == .server else { return } @@ -227,7 +260,35 @@ public struct RequestChain: Sendable { try await cacheInterceptor.writeCacheData( to: self.store, request: request, - response: result + response: response ) } } + +// MARK: - FetchBehavior Helpers + +extension FetchBehavior { + + fileprivate func shouldReadFromCache(hadFailedNetworkFetch: Bool) -> Bool { + switch self.cacheRead { + case .never: + return false + case .beforeNetworkFetch: + return !hadFailedNetworkFetch + case .onNetworkFailure: + return hadFailedNetworkFetch + } + } + + fileprivate func shouldFetchFromNetwork(hadSuccessfulCacheRead: Bool) -> Bool { + switch self.networkFetch { + case .never: + return false + case .always: + return true + 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/UploadRequest.swift b/apollo-ios/Sources/Apollo/UploadRequest.swift index 836f3e377..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. @@ -18,8 +19,14 @@ public struct UploadRequest: GraphQLRequest { /// The `FetchBehavior` to use for this request. Determines if fetching will include cache/network. public var fetchBehavior: FetchBehavior - /// [optional] A context that is being passed through the request chain. - public var context: (any RequestContext)? + /// 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? 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,13 +149,15 @@ 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.fetchBehavior == rhs.fetchBehavior && - 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) { @@ -156,6 +165,8 @@ public struct UploadRequest: GraphQLRequest { hasher.combine(operation) hasher.combine(additionalHeaders) 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 4448c9de3..842ae957c 100644 --- a/apollo-ios/Sources/ApolloAPI/GraphQLOperation.swift +++ b/apollo-ios/Sources/ApolloAPI/GraphQLOperation.swift @@ -138,7 +138,10 @@ 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 } } @@ -162,6 +165,8 @@ public struct IncrementalDeferredResponseFormat: OperationResponseFormat { } } +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 {