From 214827de9fb030d1939cb7982a51aef39207e281 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Thu, 13 Nov 2025 08:46:41 +0100 Subject: [PATCH 01/14] feat: Update minimum macOS deployment target to v11 This change updates the minimum macOS version required for the package to macOS 11. --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index b7fea4f..2ec3c5e 100644 --- a/Package.swift +++ b/Package.swift @@ -7,7 +7,7 @@ let package = Package( name: "GraphQLAPIKit", platforms: [ .iOS(.v16), - .macOS(.v10_14) + .macOS(.v11) ], products: [ .library( From 58d7950117fea3cd6f93bfab6f8e6bd0c78d831a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Tue, 13 Jan 2026 14:48:19 +0100 Subject: [PATCH 02/14] feat(package): Update Swift tools version to 5.9 and macOS deployment target to v13 --- Package.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Package.swift b/Package.swift index 2ec3c5e..a9cde24 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.8 +// swift-tools-version:5.9 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -7,7 +7,7 @@ let package = Package( name: "GraphQLAPIKit", platforms: [ .iOS(.v16), - .macOS(.v11) + .macOS(.v13) ], products: [ .library( From c3b28acab4a7b0cff6234c7d1667684c55720f48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Mon, 12 Jan 2026 13:57:12 +0100 Subject: [PATCH 03/14] feat(observers): add passive network observers for GraphQL operations Introduce GraphQLNetworkObserver protocol following FTAPIKit pattern. Observers receive lifecycle callbacks (willSendRequest, didReceiveResponse, didFail) without ability to modify requests - safer than interceptors. --- Package.swift | 6 + Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift | 66 +++++-- .../Observers/GraphQLNetworkObserver.swift | 133 +++++++++++++ .../GraphQLNetworkObserverTests.swift | 184 ++++++++++++++++++ 4 files changed, 377 insertions(+), 12 deletions(-) create mode 100644 Sources/GraphQLAPIKit/Observers/GraphQLNetworkObserver.swift create mode 100644 Tests/GraphQLAPIKitTests/GraphQLNetworkObserverTests.swift diff --git a/Package.swift b/Package.swift index a9cde24..7e529d2 100644 --- a/Package.swift +++ b/Package.swift @@ -29,6 +29,12 @@ let package = Package( dependencies: [ .product(name: "Apollo", package: "apollo-ios"), ] + ), + .testTarget( + name: "GraphQLAPIKitTests", + dependencies: [ + "GraphQLAPIKit" + ] ) ] ) diff --git a/Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift b/Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift index 5d064d4..ffccc84 100644 --- a/Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift +++ b/Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift @@ -18,7 +18,7 @@ public protocol GraphQLAPIAdapterProtocol: AnyObject { queue: DispatchQueue, resultHandler: @escaping (Result) -> Void ) -> Cancellable - + /// Performs a mutation by sending it to the server. /// /// - Parameters: @@ -37,12 +37,22 @@ public protocol GraphQLAPIAdapterProtocol: AnyObject { public final class GraphQLAPIAdapter: GraphQLAPIAdapterProtocol { private let apollo: ApolloClientProtocol + private let url: URL + + /// Array of network observers for lifecycle callbacks. + /// Each observer receives notifications before requests are sent, + /// when responses are received, and when errors occur. + private let networkObservers: [any GraphQLNetworkObserver] public init( url: URL, urlSessionConfiguration: URLSessionConfiguration = .default, - defaultHeaders: [String: String] = [:] + defaultHeaders: [String: String] = [:], + networkObservers: [any GraphQLNetworkObserver] = [] ) { + self.url = url + self.networkObservers = networkObservers + let provider = NetworkInterceptorProvider( client: URLSessionClient(sessionConfiguration: urlSessionConfiguration), defaultHeaders: defaultHeaders @@ -64,25 +74,42 @@ public final class GraphQLAPIAdapter: GraphQLAPIAdapterProtocol { context: RequestHeaders?, queue: DispatchQueue, resultHandler: @escaping (Result) -> Void - ) -> Cancellable where Query : GraphQLQuery { - apollo.fetch( + ) -> Cancellable where Query: GraphQLQuery { + // Create operation context for observers + let operationContext = GraphQLOperationContext( + operationName: Query.operationName, + operationType: "query", + url: url + ) + + // Create tokens that call willSendRequest immediately + let tokens = networkObservers.map { GraphQLRequestToken(observer: $0, context: operationContext) } + + return apollo.fetch( query: query, cachePolicy: .fetchIgnoringCacheCompletely, contextIdentifier: nil, context: context, queue: queue ) { result in + // Notify observers about response + tokens.forEach { $0.didReceiveResponse(nil, nil) } + switch result { case .success(let result): if let errors = result.errors { - resultHandler(.failure(GraphQLAPIAdapterError(error: ApolloError(errors: errors)))) + let error = GraphQLAPIAdapterError(error: ApolloError(errors: errors)) + tokens.forEach { $0.didFail(error) } + resultHandler(.failure(error)) } else if let data = result.data { resultHandler(.success(data)) } else { assertionFailure("Did not receive no data nor errors") } case .failure(let error): - resultHandler(.failure(GraphQLAPIAdapterError(error: error))) + let adapterError = GraphQLAPIAdapterError(error: error) + tokens.forEach { $0.didFail(adapterError) } + resultHandler(.failure(adapterError)) } } } @@ -92,24 +119,41 @@ public final class GraphQLAPIAdapter: GraphQLAPIAdapterProtocol { context: RequestHeaders?, queue: DispatchQueue, resultHandler: @escaping (Result) -> Void - ) -> Cancellable where Mutation : GraphQLMutation { - apollo.perform( + ) -> Cancellable where Mutation: GraphQLMutation { + // Create operation context for observers + let operationContext = GraphQLOperationContext( + operationName: Mutation.operationName, + operationType: "mutation", + url: url + ) + + // Create tokens that call willSendRequest immediately + let tokens = networkObservers.map { GraphQLRequestToken(observer: $0, context: operationContext) } + + return apollo.perform( mutation: mutation, publishResultToStore: false, context: context, queue: queue ) { result in + // Notify observers about response + tokens.forEach { $0.didReceiveResponse(nil, nil) } + switch result { case .success(let result): if let errors = result.errors { - resultHandler(.failure(GraphQLAPIAdapterError(error: ApolloError(errors: errors)))) + let error = GraphQLAPIAdapterError(error: ApolloError(errors: errors)) + tokens.forEach { $0.didFail(error) } + resultHandler(.failure(error)) } else if let data = result.data { resultHandler(.success(data)) } else { assertionFailure("Did not receive no data nor errors") } case .failure(let error): - resultHandler(.failure(GraphQLAPIAdapterError(error: error))) + let adapterError = GraphQLAPIAdapterError(error: error) + tokens.forEach { $0.didFail(adapterError) } + resultHandler(.failure(adapterError)) } } } @@ -159,5 +203,3 @@ private struct RequestHeaderInterceptor: ApolloInterceptor { chain.proceedAsync(request: request, response: response, interceptor: self, completion: completion) } } - - diff --git a/Sources/GraphQLAPIKit/Observers/GraphQLNetworkObserver.swift b/Sources/GraphQLAPIKit/Observers/GraphQLNetworkObserver.swift new file mode 100644 index 0000000..721b12a --- /dev/null +++ b/Sources/GraphQLAPIKit/Observers/GraphQLNetworkObserver.swift @@ -0,0 +1,133 @@ +import Apollo +import ApolloAPI +import Foundation + +/// Context containing GraphQL operation metadata for observers. +/// +/// This struct provides all relevant information about a GraphQL operation +/// that observers might need for logging, tracing, or analytics. +public struct GraphQLOperationContext: Sendable { + /// Name of the GraphQL operation (e.g., "GetUser", "CreatePost") + public let operationName: String + + /// Type of the operation: "query", "mutation", or "subscription" + public let operationType: String + + /// The endpoint URL + public let url: URL + + public init(operationName: String, operationType: String, url: URL) { + self.operationName = operationName + self.operationType = operationType + self.url = url + } +} + +/// Protocol for observing GraphQL network request lifecycle events. +/// +/// Implement this protocol to add logging, analytics, or request tracking to GraphQL operations. +/// Observers are passive - they cannot modify requests or responses, only observe them. +/// +/// ## Context Lifecycle +/// The `Context` associated type allows passing correlation data (request ID, start time, etc.) +/// through the request lifecycle: +/// 1. `willSendRequest` is called before the operation starts and returns a `Context` value +/// 2. `didReceiveResponse` is always called with the raw HTTP response data (useful for debugging) +/// 3. `didFail` is called additionally if the operation fails +/// 4. If the observer is deallocated before the operation completes, the context is discarded +/// and no completion callback is invoked +/// +/// ## Example +/// ```swift +/// final class LoggingObserver: GraphQLNetworkObserver { +/// struct Context: Sendable { +/// let requestId: String +/// let startTime: Date +/// } +/// +/// func willSendRequest(_ context: GraphQLOperationContext) -> Context { +/// let requestId = UUID().uuidString +/// print("[\(requestId)] → \(context.operationType) \(context.operationName)") +/// return Context(requestId: requestId, startTime: Date()) +/// } +/// +/// func didReceiveResponse( +/// for context: GraphQLOperationContext, +/// response: HTTPURLResponse?, +/// data: Data?, +/// observerContext: Context +/// ) { +/// let duration = Date().timeIntervalSince(observerContext.startTime) +/// print("[\(observerContext.requestId)] ← \(response?.statusCode ?? 0) (\(duration)s)") +/// } +/// +/// func didFail(for context: GraphQLOperationContext, error: Error, observerContext: Context) { +/// print("[\(observerContext.requestId)] ✗ \(error.localizedDescription)") +/// } +/// } +/// ``` +public protocol GraphQLNetworkObserver: AnyObject, Sendable { + associatedtype Context: Sendable + + /// Called immediately before a GraphQL operation is sent. + /// - Parameter context: Information about the operation being sent + /// - Returns: Context to be passed to `didReceiveResponse` and optionally `didFail` + func willSendRequest(_ context: GraphQLOperationContext) -> Context + + /// Called when a response is received from the server. + /// + /// This is always called with the raw HTTP response data, even if processing subsequently fails. + /// This allows observers to inspect the actual response for debugging purposes. + /// - Parameters: + /// - context: Information about the original operation + /// - response: The HTTP response (may be nil if network error occurred before response) + /// - data: Response body data, if any + /// - observerContext: Value returned from `willSendRequest` + func didReceiveResponse( + for context: GraphQLOperationContext, + response: HTTPURLResponse?, + data: Data?, + observerContext: Context + ) + + /// Called when an operation fails with an error. + /// + /// Called after `didReceiveResponse` if processing determines the operation failed. + /// - Parameters: + /// - context: Information about the original operation + /// - error: The error that occurred + /// - observerContext: Value returned from `willSendRequest` + func didFail(for context: GraphQLOperationContext, error: Error, observerContext: Context) +} + +// MARK: - Internal Type Erasure + +/// Internal struct that hides the specific observer type and its associated Context type. +/// This enables storing heterogeneous observers in an array. +struct GraphQLRequestToken: Sendable { + let didReceiveResponse: @Sendable (HTTPURLResponse?, Data?) -> Void + let didFail: @Sendable (Error) -> Void + + /// Creates a token that captures the observer and immediately calls `willSendRequest`. + /// - Parameters: + /// - observer: The observer to wrap + /// - context: The operation context + init(observer: T, context: GraphQLOperationContext) { + // Generate the observer context immediately upon initialization + let observerContext = observer.willSendRequest(context) + + // Capture the specific observer and context inside closures using weak reference + self.didReceiveResponse = { [weak observer] response, data in + observer?.didReceiveResponse( + for: context, + response: response, + data: data, + observerContext: observerContext + ) + } + + self.didFail = { [weak observer] error in + observer?.didFail(for: context, error: error, observerContext: observerContext) + } + } +} diff --git a/Tests/GraphQLAPIKitTests/GraphQLNetworkObserverTests.swift b/Tests/GraphQLAPIKitTests/GraphQLNetworkObserverTests.swift new file mode 100644 index 0000000..1c10510 --- /dev/null +++ b/Tests/GraphQLAPIKitTests/GraphQLNetworkObserverTests.swift @@ -0,0 +1,184 @@ +import XCTest +@testable import GraphQLAPIKit + +final class GraphQLNetworkObserverTests: XCTestCase { + + // MARK: - GraphQLOperationContext Tests + + func testOperationContextInitialization() { + let url = URL(string: "https://api.example.com/graphql")! + let context = GraphQLOperationContext( + operationName: "GetUser", + operationType: "query", + url: url + ) + + XCTAssertEqual(context.operationName, "GetUser") + XCTAssertEqual(context.operationType, "query") + XCTAssertEqual(context.url, url) + } + + func testOperationContextSendable() { + // Verify GraphQLOperationContext can be sent across actors + let url = URL(string: "https://api.example.com/graphql")! + let context = GraphQLOperationContext( + operationName: "CreatePost", + operationType: "mutation", + url: url + ) + + Task { + // This should compile without issues if Sendable conformance is correct + await verifyContextOnAnotherActor(context) + } + } + + @MainActor + private func verifyContextOnAnotherActor(_ context: GraphQLOperationContext) async { + XCTAssertEqual(context.operationType, "mutation") + } + + // MARK: - MockObserver + + final class MockObserver: GraphQLNetworkObserver { + struct Context: Sendable { + let requestId: String + let startTime: Date + } + + var willSendRequestCalled = false + var didReceiveResponseCalled = false + var didFailCalled = false + + var lastOperationContext: GraphQLOperationContext? + var lastResponse: HTTPURLResponse? + var lastData: Data? + var lastError: Error? + + func willSendRequest(_ context: GraphQLOperationContext) -> Context { + willSendRequestCalled = true + lastOperationContext = context + return Context(requestId: UUID().uuidString, startTime: Date()) + } + + func didReceiveResponse( + for context: GraphQLOperationContext, + response: HTTPURLResponse?, + data: Data?, + observerContext: Context + ) { + didReceiveResponseCalled = true + lastResponse = response + lastData = data + } + + func didFail(for context: GraphQLOperationContext, error: Error, observerContext: Context) { + didFailCalled = true + lastError = error + } + } + + // MARK: - GraphQLRequestToken Tests + + func testRequestTokenCallsWillSendRequestImmediately() { + let observer = MockObserver() + let context = GraphQLOperationContext( + operationName: "TestQuery", + operationType: "query", + url: URL(string: "https://example.com")! + ) + + XCTAssertFalse(observer.willSendRequestCalled) + + _ = GraphQLRequestToken(observer: observer, context: context) + + XCTAssertTrue(observer.willSendRequestCalled) + XCTAssertEqual(observer.lastOperationContext?.operationName, "TestQuery") + } + + func testRequestTokenDidReceiveResponse() { + let observer = MockObserver() + let context = GraphQLOperationContext( + operationName: "TestQuery", + operationType: "query", + url: URL(string: "https://example.com")! + ) + + let token = GraphQLRequestToken(observer: observer, context: context) + + XCTAssertFalse(observer.didReceiveResponseCalled) + + token.didReceiveResponse(nil, nil) + + XCTAssertTrue(observer.didReceiveResponseCalled) + } + + func testRequestTokenDidFail() { + let observer = MockObserver() + let context = GraphQLOperationContext( + operationName: "TestMutation", + operationType: "mutation", + url: URL(string: "https://example.com")! + ) + + let token = GraphQLRequestToken(observer: observer, context: context) + + XCTAssertFalse(observer.didFailCalled) + + let testError = NSError(domain: "Test", code: 123) + token.didFail(testError) + + XCTAssertTrue(observer.didFailCalled) + XCTAssertNotNil(observer.lastError) + } + + func testRequestTokenWeakReference() { + var observer: MockObserver? = MockObserver() + let context = GraphQLOperationContext( + operationName: "TestQuery", + operationType: "query", + url: URL(string: "https://example.com")! + ) + + let token = GraphQLRequestToken(observer: observer!, context: context) + + XCTAssertTrue(observer!.willSendRequestCalled) + + // Release the observer + observer = nil + + // Token should not crash when observer is deallocated + token.didReceiveResponse(nil, nil) + token.didFail(NSError(domain: "Test", code: 0)) + } + + // MARK: - Multiple Observers Tests + + func testMultipleObserversAllReceiveCallbacks() { + let observer1 = MockObserver() + let observer2 = MockObserver() + let observer3 = MockObserver() + + let context = GraphQLOperationContext( + operationName: "MultiTest", + operationType: "query", + url: URL(string: "https://example.com")! + ) + + let tokens = [observer1, observer2, observer3].map { + GraphQLRequestToken(observer: $0, context: context) + } + + // All should have willSendRequest called + XCTAssertTrue(observer1.willSendRequestCalled) + XCTAssertTrue(observer2.willSendRequestCalled) + XCTAssertTrue(observer3.willSendRequestCalled) + + // Call didReceiveResponse on all + tokens.forEach { $0.didReceiveResponse(nil, nil) } + + XCTAssertTrue(observer1.didReceiveResponseCalled) + XCTAssertTrue(observer2.didReceiveResponseCalled) + XCTAssertTrue(observer3.didReceiveResponseCalled) + } +} From 7bb1d0c8f6444325864e6e79c817b2c87273359c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Mon, 12 Jan 2026 15:00:54 +0100 Subject: [PATCH 04/14] refactor(observers): use URLRequest directly matching FTAPIKit pattern Replace custom GraphQLOperationContext with URLRequest for observer API, matching FTAPIKit's NetworkObserver pattern exactly. Use closure capture pattern in ObserverInterceptor for type erasure without locks. - Remove GraphQLOperationContext and GraphQLRequestToken - Add ObserverInterceptor with generic init for type erasure - Use closure capture pattern (no locks needed) - Add factory pattern in NetworkInterceptorProvider - Simplify GraphQLAPIAdapter (observer handling now in interceptor) Co-Authored-By: Claude Opus 4.5 --- Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift | 77 +++----- .../Observers/GraphQLNetworkObserver.swift | 121 ++---------- .../Observers/ObserverInterceptor.swift | 68 +++++++ .../GraphQLNetworkObserverTests.swift | 176 ++++++------------ 4 files changed, 166 insertions(+), 276 deletions(-) create mode 100644 Sources/GraphQLAPIKit/Observers/ObserverInterceptor.swift diff --git a/Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift b/Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift index ffccc84..84d64fa 100644 --- a/Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift +++ b/Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift @@ -37,12 +37,6 @@ public protocol GraphQLAPIAdapterProtocol: AnyObject { public final class GraphQLAPIAdapter: GraphQLAPIAdapterProtocol { private let apollo: ApolloClientProtocol - private let url: URL - - /// Array of network observers for lifecycle callbacks. - /// Each observer receives notifications before requests are sent, - /// when responses are received, and when errors occur. - private let networkObservers: [any GraphQLNetworkObserver] public init( url: URL, @@ -50,12 +44,12 @@ public final class GraphQLAPIAdapter: GraphQLAPIAdapterProtocol { defaultHeaders: [String: String] = [:], networkObservers: [any GraphQLNetworkObserver] = [] ) { - self.url = url - self.networkObservers = networkObservers - let provider = NetworkInterceptorProvider( client: URLSessionClient(sessionConfiguration: urlSessionConfiguration), - defaultHeaders: defaultHeaders + defaultHeaders: defaultHeaders, + observerInterceptorFactory: { + networkObservers.map { ObserverInterceptor(observer: $0) } + } ) let networkTransport = RequestChainNetworkTransport( @@ -75,41 +69,24 @@ public final class GraphQLAPIAdapter: GraphQLAPIAdapterProtocol { queue: DispatchQueue, resultHandler: @escaping (Result) -> Void ) -> Cancellable where Query: GraphQLQuery { - // Create operation context for observers - let operationContext = GraphQLOperationContext( - operationName: Query.operationName, - operationType: "query", - url: url - ) - - // Create tokens that call willSendRequest immediately - let tokens = networkObservers.map { GraphQLRequestToken(observer: $0, context: operationContext) } - - return apollo.fetch( + apollo.fetch( query: query, cachePolicy: .fetchIgnoringCacheCompletely, contextIdentifier: nil, context: context, queue: queue ) { result in - // Notify observers about response - tokens.forEach { $0.didReceiveResponse(nil, nil) } - switch result { case .success(let result): if let errors = result.errors { - let error = GraphQLAPIAdapterError(error: ApolloError(errors: errors)) - tokens.forEach { $0.didFail(error) } - resultHandler(.failure(error)) + resultHandler(.failure(GraphQLAPIAdapterError(error: ApolloError(errors: errors)))) } else if let data = result.data { resultHandler(.success(data)) } else { assertionFailure("Did not receive no data nor errors") } case .failure(let error): - let adapterError = GraphQLAPIAdapterError(error: error) - tokens.forEach { $0.didFail(adapterError) } - resultHandler(.failure(adapterError)) + resultHandler(.failure(GraphQLAPIAdapterError(error: error))) } } } @@ -120,59 +97,51 @@ public final class GraphQLAPIAdapter: GraphQLAPIAdapterProtocol { queue: DispatchQueue, resultHandler: @escaping (Result) -> Void ) -> Cancellable where Mutation: GraphQLMutation { - // Create operation context for observers - let operationContext = GraphQLOperationContext( - operationName: Mutation.operationName, - operationType: "mutation", - url: url - ) - - // Create tokens that call willSendRequest immediately - let tokens = networkObservers.map { GraphQLRequestToken(observer: $0, context: operationContext) } - - return apollo.perform( + apollo.perform( mutation: mutation, publishResultToStore: false, context: context, queue: queue ) { result in - // Notify observers about response - tokens.forEach { $0.didReceiveResponse(nil, nil) } - switch result { case .success(let result): if let errors = result.errors { - let error = GraphQLAPIAdapterError(error: ApolloError(errors: errors)) - tokens.forEach { $0.didFail(error) } - resultHandler(.failure(error)) + resultHandler(.failure(GraphQLAPIAdapterError(error: ApolloError(errors: errors)))) } else if let data = result.data { resultHandler(.success(data)) } else { assertionFailure("Did not receive no data nor errors") } case .failure(let error): - let adapterError = GraphQLAPIAdapterError(error: error) - tokens.forEach { $0.didFail(adapterError) } - resultHandler(.failure(adapterError)) + resultHandler(.failure(GraphQLAPIAdapterError(error: error))) } } } } +// MARK: - Network Interceptor Provider + private struct NetworkInterceptorProvider: InterceptorProvider { private let client: URLSessionClient private let defaultHeaders: [String: String] + private let observerInterceptorFactory: () -> [ApolloInterceptor] - init(client: URLSessionClient, defaultHeaders: [String: String]) { + init( + client: URLSessionClient, + defaultHeaders: [String: String], + observerInterceptorFactory: @escaping () -> [ApolloInterceptor] + ) { self.client = client self.defaultHeaders = defaultHeaders + self.observerInterceptorFactory = observerInterceptorFactory } func interceptors(for operation: Operation) -> [ApolloInterceptor] { - [ + // Observer interceptors first, then the standard chain + observerInterceptorFactory() + [ RequestHeaderInterceptor(defaultHeaders: defaultHeaders), MaxRetryInterceptor(), - NetworkFetchInterceptor(client: self.client), + NetworkFetchInterceptor(client: client), ResponseCodeInterceptor(), MultipartResponseParsingInterceptor(), JSONResponseParsingInterceptor() @@ -180,6 +149,8 @@ private struct NetworkInterceptorProvider: InterceptorProvider { } } +// MARK: - Request Header Interceptor + private struct RequestHeaderInterceptor: ApolloInterceptor { var id: String = UUID().uuidString diff --git a/Sources/GraphQLAPIKit/Observers/GraphQLNetworkObserver.swift b/Sources/GraphQLAPIKit/Observers/GraphQLNetworkObserver.swift index 721b12a..795510b 100644 --- a/Sources/GraphQLAPIKit/Observers/GraphQLNetworkObserver.swift +++ b/Sources/GraphQLAPIKit/Observers/GraphQLNetworkObserver.swift @@ -1,133 +1,46 @@ -import Apollo -import ApolloAPI import Foundation -/// Context containing GraphQL operation metadata for observers. -/// -/// This struct provides all relevant information about a GraphQL operation -/// that observers might need for logging, tracing, or analytics. -public struct GraphQLOperationContext: Sendable { - /// Name of the GraphQL operation (e.g., "GetUser", "CreatePost") - public let operationName: String - - /// Type of the operation: "query", "mutation", or "subscription" - public let operationType: String - - /// The endpoint URL - public let url: URL - - public init(operationName: String, operationType: String, url: URL) { - self.operationName = operationName - self.operationType = operationType - self.url = url - } -} - /// Protocol for observing GraphQL network request lifecycle events. /// /// Implement this protocol to add logging, analytics, or request tracking to GraphQL operations. /// Observers are passive - they cannot modify requests or responses, only observe them. /// +/// This protocol matches FTAPIKit's `NetworkObserver` pattern exactly, using `URLRequest` directly. +/// /// ## Context Lifecycle /// The `Context` associated type allows passing correlation data (request ID, start time, etc.) /// through the request lifecycle: -/// 1. `willSendRequest` is called before the operation starts and returns a `Context` value +/// 1. `willSendRequest` is called before the request starts and returns a `Context` value /// 2. `didReceiveResponse` is always called with the raw HTTP response data (useful for debugging) -/// 3. `didFail` is called additionally if the operation fails -/// 4. If the observer is deallocated before the operation completes, the context is discarded +/// 3. `didFail` is called additionally if the request fails +/// 4. If the observer is deallocated before the request completes, the context is discarded /// and no completion callback is invoked /// -/// ## Example -/// ```swift -/// final class LoggingObserver: GraphQLNetworkObserver { -/// struct Context: Sendable { -/// let requestId: String -/// let startTime: Date -/// } -/// -/// func willSendRequest(_ context: GraphQLOperationContext) -> Context { -/// let requestId = UUID().uuidString -/// print("[\(requestId)] → \(context.operationType) \(context.operationName)") -/// return Context(requestId: requestId, startTime: Date()) -/// } -/// -/// func didReceiveResponse( -/// for context: GraphQLOperationContext, -/// response: HTTPURLResponse?, -/// data: Data?, -/// observerContext: Context -/// ) { -/// let duration = Date().timeIntervalSince(observerContext.startTime) -/// print("[\(observerContext.requestId)] ← \(response?.statusCode ?? 0) (\(duration)s)") -/// } -/// -/// func didFail(for context: GraphQLOperationContext, error: Error, observerContext: Context) { -/// print("[\(observerContext.requestId)] ✗ \(error.localizedDescription)") -/// } -/// } -/// ``` public protocol GraphQLNetworkObserver: AnyObject, Sendable { associatedtype Context: Sendable - /// Called immediately before a GraphQL operation is sent. - /// - Parameter context: Information about the operation being sent + /// Called immediately before a request is sent. + /// - Parameter request: The URLRequest about to be sent /// - Returns: Context to be passed to `didReceiveResponse` and optionally `didFail` - func willSendRequest(_ context: GraphQLOperationContext) -> Context + func willSendRequest(_ request: URLRequest) -> Context /// Called when a response is received from the server. /// - /// This is always called with the raw HTTP response data, even if processing subsequently fails. + /// This is always called with the raw response data, even if processing subsequently fails. /// This allows observers to inspect the actual response for debugging purposes. /// - Parameters: - /// - context: Information about the original operation + /// - request: The original request /// - response: The HTTP response (may be nil if network error occurred before response) /// - data: Response body data, if any - /// - observerContext: Value returned from `willSendRequest` - func didReceiveResponse( - for context: GraphQLOperationContext, - response: HTTPURLResponse?, - data: Data?, - observerContext: Context - ) + /// - context: Value returned from `willSendRequest` + func didReceiveResponse(for request: URLRequest, response: HTTPURLResponse?, data: Data?, context: Context) - /// Called when an operation fails with an error. + /// Called when a request fails with an error. /// - /// Called after `didReceiveResponse` if processing determines the operation failed. + /// Called after `didReceiveResponse` if processing determines the request failed. /// - Parameters: - /// - context: Information about the original operation + /// - request: The original request /// - error: The error that occurred - /// - observerContext: Value returned from `willSendRequest` - func didFail(for context: GraphQLOperationContext, error: Error, observerContext: Context) -} - -// MARK: - Internal Type Erasure - -/// Internal struct that hides the specific observer type and its associated Context type. -/// This enables storing heterogeneous observers in an array. -struct GraphQLRequestToken: Sendable { - let didReceiveResponse: @Sendable (HTTPURLResponse?, Data?) -> Void - let didFail: @Sendable (Error) -> Void - - /// Creates a token that captures the observer and immediately calls `willSendRequest`. - /// - Parameters: - /// - observer: The observer to wrap - /// - context: The operation context - init(observer: T, context: GraphQLOperationContext) { - // Generate the observer context immediately upon initialization - let observerContext = observer.willSendRequest(context) - - // Capture the specific observer and context inside closures using weak reference - self.didReceiveResponse = { [weak observer] response, data in - observer?.didReceiveResponse( - for: context, - response: response, - data: data, - observerContext: observerContext - ) - } - - self.didFail = { [weak observer] error in - observer?.didFail(for: context, error: error, observerContext: observerContext) - } - } + /// - context: Value returned from `willSendRequest` + func didFail(request: URLRequest, error: Error, context: Context) } diff --git a/Sources/GraphQLAPIKit/Observers/ObserverInterceptor.swift b/Sources/GraphQLAPIKit/Observers/ObserverInterceptor.swift new file mode 100644 index 0000000..781464d --- /dev/null +++ b/Sources/GraphQLAPIKit/Observers/ObserverInterceptor.swift @@ -0,0 +1,68 @@ +import Apollo +import ApolloAPI +import Foundation + +/// Internal interceptor that observes network requests for a single observer. +/// +/// One interceptor per observer (1:1 relationship), matching FTAPIKit's RequestToken pattern. +/// Uses closure capture to store URLRequest and Context immutably - type erasure happens +/// at closure creation time. +final class ObserverInterceptor: ApolloInterceptor, @unchecked Sendable { + let id = UUID().uuidString + + /// Handlers set on first call, capturing URLRequest and Context in closures + private var didReceiveResponse: ((HTTPURLResponse?, Data?) -> Void)? + private var didFail: ((Error) -> Void)? + + /// Factory that creates the handlers - captures the observer with its concrete type + private let createHandlers: (URLRequest) -> ( + didReceiveResponse: (HTTPURLResponse?, Data?) -> Void, + didFail: (Error) -> Void + ) + + /// Creates an interceptor for the given observer. + /// The generic initializer captures the concrete Observer type and its Context. + init(observer: Observer) { + self.createHandlers = { urlRequest in + // Call willSendRequest and capture context + let context = observer.willSendRequest(urlRequest) + + // Create handlers that capture urlRequest and context immutably + let didReceiveResponse: (HTTPURLResponse?, Data?) -> Void = { [weak observer] response, data in + observer?.didReceiveResponse(for: urlRequest, response: response, data: data, context: context) + } + + let didFail: (Error) -> Void = { [weak observer] error in + observer?.didFail(request: urlRequest, error: error, context: context) + } + + return (didReceiveResponse, didFail) + } + } + + func interceptAsync( + chain: RequestChain, + request: HTTPRequest, + response: HTTPResponse?, + completion: @escaping (Result, Error>) -> Void + ) { + if response == nil { + // Before network fetch - create handlers with captured context + if let urlRequest = try? request.toURLRequest() { + let handlers = createHandlers(urlRequest) + didReceiveResponse = handlers.didReceiveResponse + didFail = handlers.didFail + } + } else { + // After network fetch - invoke captured closure + didReceiveResponse?(response?.httpResponse, response?.rawData) + } + + chain.proceedAsync(request: request, response: response, interceptor: self, completion: completion) + } + + /// Called when the operation fails + func notifyFailure(_ error: Error) { + didFail?(error) + } +} diff --git a/Tests/GraphQLAPIKitTests/GraphQLNetworkObserverTests.swift b/Tests/GraphQLAPIKitTests/GraphQLNetworkObserverTests.swift index 1c10510..8f0d1f2 100644 --- a/Tests/GraphQLAPIKitTests/GraphQLNetworkObserverTests.swift +++ b/Tests/GraphQLAPIKitTests/GraphQLNetworkObserverTests.swift @@ -3,41 +3,6 @@ import XCTest final class GraphQLNetworkObserverTests: XCTestCase { - // MARK: - GraphQLOperationContext Tests - - func testOperationContextInitialization() { - let url = URL(string: "https://api.example.com/graphql")! - let context = GraphQLOperationContext( - operationName: "GetUser", - operationType: "query", - url: url - ) - - XCTAssertEqual(context.operationName, "GetUser") - XCTAssertEqual(context.operationType, "query") - XCTAssertEqual(context.url, url) - } - - func testOperationContextSendable() { - // Verify GraphQLOperationContext can be sent across actors - let url = URL(string: "https://api.example.com/graphql")! - let context = GraphQLOperationContext( - operationName: "CreatePost", - operationType: "mutation", - url: url - ) - - Task { - // This should compile without issues if Sendable conformance is correct - await verifyContextOnAnotherActor(context) - } - } - - @MainActor - private func verifyContextOnAnotherActor(_ context: GraphQLOperationContext) async { - XCTAssertEqual(context.operationType, "mutation") - } - // MARK: - MockObserver final class MockObserver: GraphQLNetworkObserver { @@ -50,135 +15,108 @@ final class GraphQLNetworkObserverTests: XCTestCase { var didReceiveResponseCalled = false var didFailCalled = false - var lastOperationContext: GraphQLOperationContext? + var lastRequest: URLRequest? var lastResponse: HTTPURLResponse? var lastData: Data? var lastError: Error? - func willSendRequest(_ context: GraphQLOperationContext) -> Context { + func willSendRequest(_ request: URLRequest) -> Context { willSendRequestCalled = true - lastOperationContext = context + lastRequest = request return Context(requestId: UUID().uuidString, startTime: Date()) } func didReceiveResponse( - for context: GraphQLOperationContext, + for request: URLRequest, response: HTTPURLResponse?, data: Data?, - observerContext: Context + context: Context ) { didReceiveResponseCalled = true lastResponse = response lastData = data } - func didFail(for context: GraphQLOperationContext, error: Error, observerContext: Context) { + func didFail(request: URLRequest, error: Error, context: Context) { didFailCalled = true lastError = error } } - // MARK: - GraphQLRequestToken Tests + // MARK: - ObserverInterceptor Tests - func testRequestTokenCallsWillSendRequestImmediately() { + func testObserverInterceptorCreation() { let observer = MockObserver() - let context = GraphQLOperationContext( - operationName: "TestQuery", - operationType: "query", - url: URL(string: "https://example.com")! - ) + let interceptor = ObserverInterceptor(observer: observer) + XCTAssertNotNil(interceptor.id) XCTAssertFalse(observer.willSendRequestCalled) - - _ = GraphQLRequestToken(observer: observer, context: context) - - XCTAssertTrue(observer.willSendRequestCalled) - XCTAssertEqual(observer.lastOperationContext?.operationName, "TestQuery") } - func testRequestTokenDidReceiveResponse() { - let observer = MockObserver() - let context = GraphQLOperationContext( - operationName: "TestQuery", - operationType: "query", - url: URL(string: "https://example.com")! - ) - - let token = GraphQLRequestToken(observer: observer, context: context) - - XCTAssertFalse(observer.didReceiveResponseCalled) + func testObserverWeakReference() { + var observer: MockObserver? = MockObserver() + let interceptor = ObserverInterceptor(observer: observer!) - token.didReceiveResponse(nil, nil) + // Release the observer + observer = nil - XCTAssertTrue(observer.didReceiveResponseCalled) + // Interceptor should not crash when observer is deallocated + // (notifyFailure should safely do nothing) + interceptor.notifyFailure(NSError(domain: "Test", code: 0)) } - func testRequestTokenDidFail() { - let observer = MockObserver() - let context = GraphQLOperationContext( - operationName: "TestMutation", - operationType: "mutation", - url: URL(string: "https://example.com")! - ) + // MARK: - Multiple Observers Tests - let token = GraphQLRequestToken(observer: observer, context: context) + func testMultipleInterceptorsCreation() { + let observer1 = MockObserver() + let observer2 = MockObserver() + let observer3 = MockObserver() - XCTAssertFalse(observer.didFailCalled) + let interceptors = [observer1, observer2, observer3].map { + ObserverInterceptor(observer: $0) + } - let testError = NSError(domain: "Test", code: 123) - token.didFail(testError) + XCTAssertEqual(interceptors.count, 3) - XCTAssertTrue(observer.didFailCalled) - XCTAssertNotNil(observer.lastError) + // All should have unique IDs + let ids = Set(interceptors.map { $0.id }) + XCTAssertEqual(ids.count, 3) } - func testRequestTokenWeakReference() { - var observer: MockObserver? = MockObserver() - let context = GraphQLOperationContext( - operationName: "TestQuery", - operationType: "query", - url: URL(string: "https://example.com")! - ) - - let token = GraphQLRequestToken(observer: observer!, context: context) + // MARK: - Protocol Conformance Tests - XCTAssertTrue(observer!.willSendRequestCalled) - - // Release the observer - observer = nil + func testProtocolMethodSignatures() { + // This test verifies the protocol matches FTAPIKit's NetworkObserver pattern + let observer = MockObserver() - // Token should not crash when observer is deallocated - token.didReceiveResponse(nil, nil) - token.didFail(NSError(domain: "Test", code: 0)) - } + // Create a sample URLRequest + let request = URLRequest(url: URL(string: "https://example.com/graphql")!) - // MARK: - Multiple Observers Tests - - func testMultipleObserversAllReceiveCallbacks() { - let observer1 = MockObserver() - let observer2 = MockObserver() - let observer3 = MockObserver() + // Test willSendRequest returns Context + let context = observer.willSendRequest(request) + XCTAssertTrue(observer.willSendRequestCalled) + XCTAssertNotNil(context.requestId) - let context = GraphQLOperationContext( - operationName: "MultiTest", - operationType: "query", - url: URL(string: "https://example.com")! - ) + // Test didReceiveResponse + observer.didReceiveResponse(for: request, response: nil, data: nil, context: context) + XCTAssertTrue(observer.didReceiveResponseCalled) - let tokens = [observer1, observer2, observer3].map { - GraphQLRequestToken(observer: $0, context: context) - } + // Test didFail + observer.didFail(request: request, error: NSError(domain: "Test", code: 1), context: context) + XCTAssertTrue(observer.didFailCalled) + } - // All should have willSendRequest called - XCTAssertTrue(observer1.willSendRequestCalled) - XCTAssertTrue(observer2.willSendRequestCalled) - XCTAssertTrue(observer3.willSendRequestCalled) + func testURLRequestPassedCorrectly() { + let observer = MockObserver() + let url = URL(string: "https://api.example.com/graphql")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") - // Call didReceiveResponse on all - tokens.forEach { $0.didReceiveResponse(nil, nil) } + _ = observer.willSendRequest(request) - XCTAssertTrue(observer1.didReceiveResponseCalled) - XCTAssertTrue(observer2.didReceiveResponseCalled) - XCTAssertTrue(observer3.didReceiveResponseCalled) + XCTAssertEqual(observer.lastRequest?.url, url) + XCTAssertEqual(observer.lastRequest?.httpMethod, "POST") + XCTAssertEqual(observer.lastRequest?.value(forHTTPHeaderField: "Content-Type"), "application/json") } } From 1f814d283955eab0bf0f3739712ef7a8be0d6378 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Mon, 12 Jan 2026 15:02:55 +0100 Subject: [PATCH 05/14] fix(observers): move observers after header interceptor Ensures observers see URLRequest with all headers applied (defaultHeaders and additionalHeaders from context). Co-Authored-By: Claude Opus 4.5 --- Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift b/Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift index 84d64fa..9916370 100644 --- a/Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift +++ b/Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift @@ -137,15 +137,16 @@ private struct NetworkInterceptorProvider: InterceptorProvider { } func interceptors(for operation: Operation) -> [ApolloInterceptor] { - // Observer interceptors first, then the standard chain - observerInterceptorFactory() + [ - RequestHeaderInterceptor(defaultHeaders: defaultHeaders), - MaxRetryInterceptor(), - NetworkFetchInterceptor(client: client), - ResponseCodeInterceptor(), - MultipartResponseParsingInterceptor(), - JSONResponseParsingInterceptor() - ] + // Headers first, then observers (so they see final URLRequest), then network chain + [RequestHeaderInterceptor(defaultHeaders: defaultHeaders)] + + observerInterceptorFactory() + + [ + MaxRetryInterceptor(), + NetworkFetchInterceptor(client: client), + ResponseCodeInterceptor(), + MultipartResponseParsingInterceptor(), + JSONResponseParsingInterceptor() + ] } } From 89e7a5944c0f761d0c369f2367bd05623dc35148 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Mon, 12 Jan 2026 15:28:39 +0100 Subject: [PATCH 06/14] test(observers): add integration tests for header capture Add GraphQLAPIAdapterIntegrationTests with MockURLProtocol to verify: - Observer receives default headers - Observer receives context headers (RequestHeaders) - Observer receives both default and context headers - Multiple observers all receive same headers - Observer sees Apollo-added headers (X-APOLLO-OPERATION-NAME, etc.) Also add unit tests for header capture in GraphQLNetworkObserverTests: - testObserverReceivesHeadersFromURLRequest - testMultipleObserversReceiveSameHeaders - testInterceptorChainOrderPlacesObserversAfterHeaders Co-Authored-By: Claude Opus 4.5 --- .../GraphQLAPIAdapterIntegrationTests.swift | 344 ++++++++++++++++++ .../GraphQLNetworkObserverTests.swift | 87 +++++ 2 files changed, 431 insertions(+) create mode 100644 Tests/GraphQLAPIKitTests/GraphQLAPIAdapterIntegrationTests.swift diff --git a/Tests/GraphQLAPIKitTests/GraphQLAPIAdapterIntegrationTests.swift b/Tests/GraphQLAPIKitTests/GraphQLAPIAdapterIntegrationTests.swift new file mode 100644 index 0000000..8a0279a --- /dev/null +++ b/Tests/GraphQLAPIKitTests/GraphQLAPIAdapterIntegrationTests.swift @@ -0,0 +1,344 @@ +import Apollo +import ApolloAPI +import XCTest +@testable import GraphQLAPIKit + +// MARK: - MockURLProtocol + +final class MockURLProtocol: URLProtocol { + /// Captured requests for verification + static var capturedRequests: [URLRequest] = [] + + /// Response to return + static var mockResponse: (data: Data, statusCode: Int)? + + /// Error to return + static var mockError: Error? + + /// Reset state between tests + static func reset() { + capturedRequests = [] + mockResponse = nil + mockError = nil + } + + override class func canInit(with request: URLRequest) -> Bool { + true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + // Capture the request + MockURLProtocol.capturedRequests.append(request) + + if let error = MockURLProtocol.mockError { + client?.urlProtocol(self, didFailWithError: error) + return + } + + let response = MockURLProtocol.mockResponse ?? ( + data: validGraphQLResponse, + statusCode: 200 + ) + + let httpResponse = HTTPURLResponse( + url: request.url!, + statusCode: response.statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )! + + client?.urlProtocol(self, didReceive: httpResponse, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: response.data) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} + + /// A valid GraphQL response with minimal data + private var validGraphQLResponse: Data { + """ + {"data": {"__typename": "Query"}} + """.data(using: .utf8)! + } +} + +// MARK: - Mock GraphQL Schema and Query + +enum MockSchema: SchemaMetadata { + static let configuration: any SchemaConfiguration.Type = MockSchemaConfiguration.self + + static func objectType(forTypename typename: String) -> Object? { + if typename == "Query" { return MockQuery.Data.self.__parentType as? Object } + return nil + } +} + +enum MockSchemaConfiguration: SchemaConfiguration { + static func cacheKeyInfo(for type: Object, object: ObjectData) -> CacheKeyInfo? { + nil + } +} + +/// Minimal mock query for testing +final class MockQuery: GraphQLQuery { + typealias Data = MockQueryData + + static let operationName: String = "MockQuery" + static let operationDocument: OperationDocument = OperationDocument( + definition: .init("query MockQuery { __typename }") + ) + + init() {} + + struct MockQueryData: RootSelectionSet { + typealias Schema = MockSchema + + static var __parentType: any ParentType { Object(typename: "Query", implementedInterfaces: []) } + static var __selections: [Selection] { [] } + + var __data: DataDict + + init(_dataDict: DataDict) { + self.__data = _dataDict + } + } +} + +// MARK: - Mock Request Headers + +struct MockRequestHeaders: RequestHeaders { + let additionalHeaders: [String: String] +} + +// MARK: - Mock Observer for Integration Tests + +final class IntegrationMockObserver: GraphQLNetworkObserver { + struct Context: Sendable { + let timestamp: Date + } + + var capturedRequests: [URLRequest] = [] + var capturedResponses: [(response: HTTPURLResponse?, data: Data?)] = [] + var capturedErrors: [Error] = [] + + func willSendRequest(_ request: URLRequest) -> Context { + capturedRequests.append(request) + return Context(timestamp: Date()) + } + + func didReceiveResponse(for request: URLRequest, response: HTTPURLResponse?, data: Data?, context: Context) { + capturedResponses.append((response, data)) + } + + func didFail(request: URLRequest, error: Error, context: Context) { + capturedErrors.append(error) + } +} + +// MARK: - Integration Tests + +final class GraphQLAPIAdapterIntegrationTests: XCTestCase { + + override func setUp() { + super.setUp() + MockURLProtocol.reset() + } + + override func tearDown() { + MockURLProtocol.reset() + super.tearDown() + } + + /// Creates a URLSessionConfiguration that uses MockURLProtocol + private func mockSessionConfiguration() -> URLSessionConfiguration { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MockURLProtocol.self] + return config + } + + // MARK: - Default Headers Tests + + func testObserverReceivesDefaultHeaders() { + let expectation = expectation(description: "Request completed") + + let observer = IntegrationMockObserver() + let defaultHeaders = [ + "X-API-Key": "test-api-key", + "X-Client-Version": "1.0.0" + ] + + let adapter = GraphQLAPIAdapter( + url: URL(string: "https://api.example.com/graphql")!, + urlSessionConfiguration: mockSessionConfiguration(), + defaultHeaders: defaultHeaders, + networkObservers: [observer] + ) + + _ = adapter.fetch(query: MockQuery(), context: nil, queue: .main) { _ in + expectation.fulfill() + } + + waitForExpectations(timeout: 5) + + // Verify observer captured the request + XCTAssertEqual(observer.capturedRequests.count, 1) + + guard let capturedRequest = observer.capturedRequests.first else { + XCTFail("No request captured") + return + } + + // Verify default headers are present + XCTAssertEqual(capturedRequest.value(forHTTPHeaderField: "X-API-Key"), "test-api-key") + XCTAssertEqual(capturedRequest.value(forHTTPHeaderField: "X-Client-Version"), "1.0.0") + } + + func testObserverReceivesContextHeaders() { + let expectation = expectation(description: "Request completed") + + let observer = IntegrationMockObserver() + let contextHeaders = MockRequestHeaders(additionalHeaders: [ + "Authorization": "Bearer test-token", + "X-Request-ID": "request-123" + ]) + + let adapter = GraphQLAPIAdapter( + url: URL(string: "https://api.example.com/graphql")!, + urlSessionConfiguration: mockSessionConfiguration(), + defaultHeaders: [:], + networkObservers: [observer] + ) + + _ = adapter.fetch(query: MockQuery(), context: contextHeaders, queue: .main) { _ in + expectation.fulfill() + } + + waitForExpectations(timeout: 5) + + // Verify observer captured the request + XCTAssertEqual(observer.capturedRequests.count, 1) + + guard let capturedRequest = observer.capturedRequests.first else { + XCTFail("No request captured") + return + } + + // Verify context headers are present + XCTAssertEqual(capturedRequest.value(forHTTPHeaderField: "Authorization"), "Bearer test-token") + XCTAssertEqual(capturedRequest.value(forHTTPHeaderField: "X-Request-ID"), "request-123") + } + + func testObserverReceivesBothDefaultAndContextHeaders() { + let expectation = expectation(description: "Request completed") + + let observer = IntegrationMockObserver() + let defaultHeaders = [ + "X-API-Key": "api-key-456", + "Accept-Language": "en-US" + ] + let contextHeaders = MockRequestHeaders(additionalHeaders: [ + "Authorization": "Bearer context-token", + "X-Trace-ID": "trace-789" + ]) + + let adapter = GraphQLAPIAdapter( + url: URL(string: "https://api.example.com/graphql")!, + urlSessionConfiguration: mockSessionConfiguration(), + defaultHeaders: defaultHeaders, + networkObservers: [observer] + ) + + _ = adapter.fetch(query: MockQuery(), context: contextHeaders, queue: .main) { _ in + expectation.fulfill() + } + + waitForExpectations(timeout: 5) + + guard let capturedRequest = observer.capturedRequests.first else { + XCTFail("No request captured") + return + } + + // Verify both default and context headers are present + XCTAssertEqual(capturedRequest.value(forHTTPHeaderField: "X-API-Key"), "api-key-456") + XCTAssertEqual(capturedRequest.value(forHTTPHeaderField: "Accept-Language"), "en-US") + XCTAssertEqual(capturedRequest.value(forHTTPHeaderField: "Authorization"), "Bearer context-token") + XCTAssertEqual(capturedRequest.value(forHTTPHeaderField: "X-Trace-ID"), "trace-789") + } + + // MARK: - Multiple Observers Tests + + func testMultipleObserversAllReceiveHeaders() { + let expectation = expectation(description: "Request completed") + + let observer1 = IntegrationMockObserver() + let observer2 = IntegrationMockObserver() + let observer3 = IntegrationMockObserver() + + let defaultHeaders = ["X-Shared-Header": "shared-value"] + + let adapter = GraphQLAPIAdapter( + url: URL(string: "https://api.example.com/graphql")!, + urlSessionConfiguration: mockSessionConfiguration(), + defaultHeaders: defaultHeaders, + networkObservers: [observer1, observer2, observer3] + ) + + _ = adapter.fetch(query: MockQuery(), context: nil, queue: .main) { _ in + expectation.fulfill() + } + + waitForExpectations(timeout: 5) + + // Verify all observers captured the request with headers + for (index, observer) in [observer1, observer2, observer3].enumerated() { + XCTAssertEqual(observer.capturedRequests.count, 1, "Observer \(index + 1) should have captured 1 request") + + guard let capturedRequest = observer.capturedRequests.first else { + XCTFail("Observer \(index + 1) did not capture request") + continue + } + + XCTAssertEqual( + capturedRequest.value(forHTTPHeaderField: "X-Shared-Header"), + "shared-value", + "Observer \(index + 1) should see the shared header" + ) + } + } + + // MARK: - Apollo Headers Tests + + func testObserverReceivesApolloHeaders() { + let expectation = expectation(description: "Request completed") + + let observer = IntegrationMockObserver() + + let adapter = GraphQLAPIAdapter( + url: URL(string: "https://api.example.com/graphql")!, + urlSessionConfiguration: mockSessionConfiguration(), + defaultHeaders: [:], + networkObservers: [observer] + ) + + _ = adapter.fetch(query: MockQuery(), context: nil, queue: .main) { _ in + expectation.fulfill() + } + + waitForExpectations(timeout: 5) + + guard let capturedRequest = observer.capturedRequests.first else { + XCTFail("No request captured") + return + } + + // Verify Apollo automatically adds these headers + XCTAssertEqual(capturedRequest.value(forHTTPHeaderField: "X-APOLLO-OPERATION-NAME"), "MockQuery") + XCTAssertNotNil(capturedRequest.value(forHTTPHeaderField: "Content-Type")) + } + +} diff --git a/Tests/GraphQLAPIKitTests/GraphQLNetworkObserverTests.swift b/Tests/GraphQLAPIKitTests/GraphQLNetworkObserverTests.swift index 8f0d1f2..39c6422 100644 --- a/Tests/GraphQLAPIKitTests/GraphQLNetworkObserverTests.swift +++ b/Tests/GraphQLAPIKitTests/GraphQLNetworkObserverTests.swift @@ -119,4 +119,91 @@ final class GraphQLNetworkObserverTests: XCTestCase { XCTAssertEqual(observer.lastRequest?.httpMethod, "POST") XCTAssertEqual(observer.lastRequest?.value(forHTTPHeaderField: "Content-Type"), "application/json") } + + // MARK: - Header Capture Tests + + func testObserverReceivesHeadersFromURLRequest() { + // Test that when willSendRequest is called with a URLRequest containing headers, + // the observer can access all those headers + let observer = MockObserver() + let url = URL(string: "https://api.example.com/graphql")! + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer test-token", forHTTPHeaderField: "Authorization") + request.setValue("custom-value", forHTTPHeaderField: "X-Custom-Header") + request.setValue("en-US", forHTTPHeaderField: "Accept-Language") + + _ = observer.willSendRequest(request) + + // Verify observer received the request with all headers + XCTAssertTrue(observer.willSendRequestCalled) + XCTAssertNotNil(observer.lastRequest) + + // Check all custom headers are present + XCTAssertEqual(observer.lastRequest?.value(forHTTPHeaderField: "Content-Type"), "application/json") + XCTAssertEqual(observer.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer test-token") + XCTAssertEqual(observer.lastRequest?.value(forHTTPHeaderField: "X-Custom-Header"), "custom-value") + XCTAssertEqual(observer.lastRequest?.value(forHTTPHeaderField: "Accept-Language"), "en-US") + } + + func testMultipleObserversReceiveSameHeaders() { + // Test that multiple observers all receive the same URLRequest with headers + let observer1 = MockObserver() + let observer2 = MockObserver() + let observer3 = MockObserver() + + let url = URL(string: "https://api.example.com/graphql")! + var request = URLRequest(url: url) + request.setValue("Bearer shared-token", forHTTPHeaderField: "Authorization") + request.setValue("api-key-123", forHTTPHeaderField: "X-API-Key") + + // Simulate what happens when multiple interceptors call willSendRequest + _ = observer1.willSendRequest(request) + _ = observer2.willSendRequest(request) + _ = observer3.willSendRequest(request) + + // All observers should have received the same headers + for observer in [observer1, observer2, observer3] { + XCTAssertTrue(observer.willSendRequestCalled) + XCTAssertEqual(observer.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer shared-token") + XCTAssertEqual(observer.lastRequest?.value(forHTTPHeaderField: "X-API-Key"), "api-key-123") + } + } + + // MARK: - Interceptor Chain Order Tests + + func testInterceptorChainOrderPlacesObserversAfterHeaders() { + // This test verifies that the interceptor chain is ordered correctly: + // RequestHeaderInterceptor -> ObserverInterceptors -> NetworkFetchInterceptor... + // + // We verify this by checking that when HTTPRequest.addHeader is called (by RequestHeaderInterceptor), + // and then toURLRequest() is called (by ObserverInterceptor), the headers are present. + + let url = URL(string: "https://api.example.com/graphql")! + + // Create a mock HTTPRequest-like structure to simulate the flow + var additionalHeaders: [String: String] = [:] + + // Step 1: RequestHeaderInterceptor adds default headers + additionalHeaders["X-Default-Header"] = "default-value" + + // Step 2: RequestHeaderInterceptor adds context headers (additionalHeaders from RequestHeaders) + additionalHeaders["Authorization"] = "Bearer context-token" + + // Step 3: Create URLRequest (simulating what happens in ObserverInterceptor) + var urlRequest = URLRequest(url: url) + for (name, value) in additionalHeaders { + urlRequest.addValue(value, forHTTPHeaderField: name) + } + + // Step 4: Observer receives the request + let observer = MockObserver() + _ = observer.willSendRequest(urlRequest) + + // Verify observer sees both default and context headers + XCTAssertEqual(observer.lastRequest?.value(forHTTPHeaderField: "X-Default-Header"), "default-value") + XCTAssertEqual(observer.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer context-token") + } } From fcec6d695d3c392eaf8cdf642e4af0cee5bf20f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Mon, 12 Jan 2026 15:38:39 +0100 Subject: [PATCH 07/14] refactor(observers): unify response type with FTAPIKit (URLResponse) Change didReceiveResponse signature from HTTPURLResponse? to URLResponse? to match FTAPIKit's NetworkObserver protocol exactly. This allows a single class to conform to both protocols without method signature conflicts. Co-Authored-By: Claude Opus 4.5 --- .../GraphQLAPIKit/Observers/GraphQLNetworkObserver.swift | 4 ++-- Sources/GraphQLAPIKit/Observers/ObserverInterceptor.swift | 6 +++--- .../GraphQLAPIAdapterIntegrationTests.swift | 4 ++-- Tests/GraphQLAPIKitTests/GraphQLNetworkObserverTests.swift | 4 ++-- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Sources/GraphQLAPIKit/Observers/GraphQLNetworkObserver.swift b/Sources/GraphQLAPIKit/Observers/GraphQLNetworkObserver.swift index 795510b..6d90d49 100644 --- a/Sources/GraphQLAPIKit/Observers/GraphQLNetworkObserver.swift +++ b/Sources/GraphQLAPIKit/Observers/GraphQLNetworkObserver.swift @@ -30,10 +30,10 @@ public protocol GraphQLNetworkObserver: AnyObject, Sendable { /// This allows observers to inspect the actual response for debugging purposes. /// - Parameters: /// - request: The original request - /// - response: The HTTP response (may be nil if network error occurred before response) + /// - response: The URL response (may be nil if network error occurred before response) /// - data: Response body data, if any /// - context: Value returned from `willSendRequest` - func didReceiveResponse(for request: URLRequest, response: HTTPURLResponse?, data: Data?, context: Context) + func didReceiveResponse(for request: URLRequest, response: URLResponse?, data: Data?, context: Context) /// Called when a request fails with an error. /// diff --git a/Sources/GraphQLAPIKit/Observers/ObserverInterceptor.swift b/Sources/GraphQLAPIKit/Observers/ObserverInterceptor.swift index 781464d..ff0ec3a 100644 --- a/Sources/GraphQLAPIKit/Observers/ObserverInterceptor.swift +++ b/Sources/GraphQLAPIKit/Observers/ObserverInterceptor.swift @@ -11,12 +11,12 @@ final class ObserverInterceptor: ApolloInterceptor, @unchecked Sendable { let id = UUID().uuidString /// Handlers set on first call, capturing URLRequest and Context in closures - private var didReceiveResponse: ((HTTPURLResponse?, Data?) -> Void)? + private var didReceiveResponse: ((URLResponse?, Data?) -> Void)? private var didFail: ((Error) -> Void)? /// Factory that creates the handlers - captures the observer with its concrete type private let createHandlers: (URLRequest) -> ( - didReceiveResponse: (HTTPURLResponse?, Data?) -> Void, + didReceiveResponse: (URLResponse?, Data?) -> Void, didFail: (Error) -> Void ) @@ -28,7 +28,7 @@ final class ObserverInterceptor: ApolloInterceptor, @unchecked Sendable { let context = observer.willSendRequest(urlRequest) // Create handlers that capture urlRequest and context immutably - let didReceiveResponse: (HTTPURLResponse?, Data?) -> Void = { [weak observer] response, data in + let didReceiveResponse: (URLResponse?, Data?) -> Void = { [weak observer] response, data in observer?.didReceiveResponse(for: urlRequest, response: response, data: data, context: context) } diff --git a/Tests/GraphQLAPIKitTests/GraphQLAPIAdapterIntegrationTests.swift b/Tests/GraphQLAPIKitTests/GraphQLAPIAdapterIntegrationTests.swift index 8a0279a..e9f7aef 100644 --- a/Tests/GraphQLAPIKitTests/GraphQLAPIAdapterIntegrationTests.swift +++ b/Tests/GraphQLAPIKitTests/GraphQLAPIAdapterIntegrationTests.swift @@ -122,7 +122,7 @@ final class IntegrationMockObserver: GraphQLNetworkObserver { } var capturedRequests: [URLRequest] = [] - var capturedResponses: [(response: HTTPURLResponse?, data: Data?)] = [] + var capturedResponses: [(response: URLResponse?, data: Data?)] = [] var capturedErrors: [Error] = [] func willSendRequest(_ request: URLRequest) -> Context { @@ -130,7 +130,7 @@ final class IntegrationMockObserver: GraphQLNetworkObserver { return Context(timestamp: Date()) } - func didReceiveResponse(for request: URLRequest, response: HTTPURLResponse?, data: Data?, context: Context) { + func didReceiveResponse(for request: URLRequest, response: URLResponse?, data: Data?, context: Context) { capturedResponses.append((response, data)) } diff --git a/Tests/GraphQLAPIKitTests/GraphQLNetworkObserverTests.swift b/Tests/GraphQLAPIKitTests/GraphQLNetworkObserverTests.swift index 39c6422..f500958 100644 --- a/Tests/GraphQLAPIKitTests/GraphQLNetworkObserverTests.swift +++ b/Tests/GraphQLAPIKitTests/GraphQLNetworkObserverTests.swift @@ -16,7 +16,7 @@ final class GraphQLNetworkObserverTests: XCTestCase { var didFailCalled = false var lastRequest: URLRequest? - var lastResponse: HTTPURLResponse? + var lastResponse: URLResponse? var lastData: Data? var lastError: Error? @@ -28,7 +28,7 @@ final class GraphQLNetworkObserverTests: XCTestCase { func didReceiveResponse( for request: URLRequest, - response: HTTPURLResponse?, + response: URLResponse?, data: Data?, context: Context ) { From ecc6c1d9f76903b783561bc4abc4d1a487d17ab5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Mon, 12 Jan 2026 18:42:00 +0100 Subject: [PATCH 08/14] feat(observers): Introduce stateful network observer interceptors Refactor network observer integration to allow state sharing between pre-network-request and post-network-response/error phases using an `ObserverContextStore` actor. This enables observers to maintain a consistent context throughout a single network operation. Also, update macOS deployment target to v12. # Conflicts: # Package.swift --- Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift | 44 +++++---- .../Observers/ObserverContextStore.swift | 15 +++ .../Observers/ObserverInterceptor.swift | 96 ++++++++++--------- .../GraphQLNetworkObserverTests.swift | 61 +++++++++--- 4 files changed, 143 insertions(+), 73 deletions(-) create mode 100644 Sources/GraphQLAPIKit/Observers/ObserverContextStore.swift diff --git a/Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift b/Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift index 9916370..5468863 100644 --- a/Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift +++ b/Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift @@ -47,9 +47,7 @@ public final class GraphQLAPIAdapter: GraphQLAPIAdapterProtocol { let provider = NetworkInterceptorProvider( client: URLSessionClient(sessionConfiguration: urlSessionConfiguration), defaultHeaders: defaultHeaders, - observerInterceptorFactory: { - networkObservers.map { ObserverInterceptor(observer: $0) } - } + networkObservers: networkObservers ) let networkTransport = RequestChainNetworkTransport( @@ -124,29 +122,43 @@ public final class GraphQLAPIAdapter: GraphQLAPIAdapterProtocol { private struct NetworkInterceptorProvider: InterceptorProvider { private let client: URLSessionClient private let defaultHeaders: [String: String] - private let observerInterceptorFactory: () -> [ApolloInterceptor] + private let pairOfObserverInterceptors: [(before: ApolloInterceptor, after: ApolloInterceptor)] init( client: URLSessionClient, defaultHeaders: [String: String], - observerInterceptorFactory: @escaping () -> [ApolloInterceptor] + networkObservers: [any GraphQLNetworkObserver] ) { self.client = client self.defaultHeaders = defaultHeaders - self.observerInterceptorFactory = observerInterceptorFactory + // Create interceptor pairs with shared context stores + self.pairOfObserverInterceptors = networkObservers.map { Self.makePair(of: $0) } + } func interceptors(for operation: Operation) -> [ApolloInterceptor] { - // Headers first, then observers (so they see final URLRequest), then network chain - [RequestHeaderInterceptor(defaultHeaders: defaultHeaders)] - + observerInterceptorFactory() - + [ - MaxRetryInterceptor(), - NetworkFetchInterceptor(client: client), - ResponseCodeInterceptor(), - MultipartResponseParsingInterceptor(), - JSONResponseParsingInterceptor() - ] + // Headers first, then before-observers, then network fetch, then after-observers + [ + RequestHeaderInterceptor(defaultHeaders: defaultHeaders), + ] + + pairOfObserverInterceptors.map(\.before) // Before network - captures timing + + [ + MaxRetryInterceptor(), + NetworkFetchInterceptor(client: client) + ] + + pairOfObserverInterceptors.map(\.after) // After network - captures response + + [ + ResponseCodeInterceptor(), + MultipartResponseParsingInterceptor(), + JSONResponseParsingInterceptor() + ] + } + + static private func makePair(of observer: T) -> (before: ApolloInterceptor, after: ApolloInterceptor) { + let contextStore = ObserverContextStore() + let beforeInterceptor = ObserverInterceptor(observer: observer, contextStore: contextStore) + let afterInterceptor = ObserverInterceptor(observer: observer, contextStore: contextStore) + return (before: beforeInterceptor, after: afterInterceptor) } } diff --git a/Sources/GraphQLAPIKit/Observers/ObserverContextStore.swift b/Sources/GraphQLAPIKit/Observers/ObserverContextStore.swift new file mode 100644 index 0000000..83fac13 --- /dev/null +++ b/Sources/GraphQLAPIKit/Observers/ObserverContextStore.swift @@ -0,0 +1,15 @@ +import Foundation + +/// Actor that stores observer contexts keyed by request identifier. +/// Enables two interceptor instances to share state across the interceptor chain. +actor ObserverContextStore { + private var contexts: [String: Context] = [:] + + func store(_ context: Context, for requestId: String) { + contexts[requestId] = context + } + + func retrieve(for requestId: String) -> Context? { + contexts.removeValue(forKey: requestId) + } +} diff --git a/Sources/GraphQLAPIKit/Observers/ObserverInterceptor.swift b/Sources/GraphQLAPIKit/Observers/ObserverInterceptor.swift index ff0ec3a..f0cf4a8 100644 --- a/Sources/GraphQLAPIKit/Observers/ObserverInterceptor.swift +++ b/Sources/GraphQLAPIKit/Observers/ObserverInterceptor.swift @@ -2,42 +2,19 @@ import Apollo import ApolloAPI import Foundation -/// Internal interceptor that observes network requests for a single observer. -/// -/// One interceptor per observer (1:1 relationship), matching FTAPIKit's RequestToken pattern. -/// Uses closure capture to store URLRequest and Context immutably - type erasure happens -/// at closure creation time. -final class ObserverInterceptor: ApolloInterceptor, @unchecked Sendable { +/// Interceptor that observes network requests. Place TWO instances in chain: +/// - One BEFORE NetworkFetchInterceptor (captures request timing) +/// - One AFTER NetworkFetchInterceptor (captures response) +/// Both instances share state via the contextStore actor. +final class ObserverInterceptor: ApolloInterceptor, @unchecked Sendable { let id = UUID().uuidString - /// Handlers set on first call, capturing URLRequest and Context in closures - private var didReceiveResponse: ((URLResponse?, Data?) -> Void)? - private var didFail: ((Error) -> Void)? + private let observer: Observer + private let contextStore: ObserverContextStore - /// Factory that creates the handlers - captures the observer with its concrete type - private let createHandlers: (URLRequest) -> ( - didReceiveResponse: (URLResponse?, Data?) -> Void, - didFail: (Error) -> Void - ) - - /// Creates an interceptor for the given observer. - /// The generic initializer captures the concrete Observer type and its Context. - init(observer: Observer) { - self.createHandlers = { urlRequest in - // Call willSendRequest and capture context - let context = observer.willSendRequest(urlRequest) - - // Create handlers that capture urlRequest and context immutably - let didReceiveResponse: (URLResponse?, Data?) -> Void = { [weak observer] response, data in - observer?.didReceiveResponse(for: urlRequest, response: response, data: data, context: context) - } - - let didFail: (Error) -> Void = { [weak observer] error in - observer?.didFail(request: urlRequest, error: error, context: context) - } - - return (didReceiveResponse, didFail) - } + init(observer: Observer, contextStore: ObserverContextStore) { + self.observer = observer + self.contextStore = contextStore } func interceptAsync( @@ -46,23 +23,52 @@ final class ObserverInterceptor: ApolloInterceptor, @unchecked Sendable { response: HTTPResponse?, completion: @escaping (Result, Error>) -> Void ) { + guard let urlRequest = try? request.toURLRequest() else { + chain.proceedAsync(request: request, response: response, interceptor: self, completion: completion) + return + } + + let requestId = urlRequest.hashValue.description + if response == nil { - // Before network fetch - create handlers with captured context - if let urlRequest = try? request.toURLRequest() { - let handlers = createHandlers(urlRequest) - didReceiveResponse = handlers.didReceiveResponse - didFail = handlers.didFail + // BEFORE network fetch - call willSendRequest and store context + let context = observer.willSendRequest(urlRequest) + Task { + await contextStore.store(context, for: requestId) } } else { - // After network fetch - invoke captured closure - didReceiveResponse?(response?.httpResponse, response?.rawData) + // AFTER network fetch - retrieve context and call didReceiveResponse + Task { [weak self] in + guard let self, + let context = await contextStore.retrieve(for: requestId) else { + return + } + self.observer.didReceiveResponse( + for: urlRequest, + response: response?.httpResponse, + data: response?.rawData, + context: context + ) + } } - chain.proceedAsync(request: request, response: response, interceptor: self, completion: completion) - } + // Wrap completion to handle errors + let wrappedCompletion: (Result, Error>) -> Void = { [weak self] result in + if case .failure(let error) = result { + Task { + guard let self, + let context = await self.contextStore.retrieve(for: requestId) else { + completion(result) + return + } + self.observer.didFail(request: urlRequest, error: error, context: context) + completion(result) + } + return + } + completion(result) + } - /// Called when the operation fails - func notifyFailure(_ error: Error) { - didFail?(error) + chain.proceedAsync(request: request, response: response, interceptor: self, completion: wrappedCompletion) } } diff --git a/Tests/GraphQLAPIKitTests/GraphQLNetworkObserverTests.swift b/Tests/GraphQLAPIKitTests/GraphQLNetworkObserverTests.swift index f500958..5690435 100644 --- a/Tests/GraphQLAPIKitTests/GraphQLNetworkObserverTests.swift +++ b/Tests/GraphQLAPIKitTests/GraphQLNetworkObserverTests.swift @@ -47,22 +47,21 @@ final class GraphQLNetworkObserverTests: XCTestCase { func testObserverInterceptorCreation() { let observer = MockObserver() - let interceptor = ObserverInterceptor(observer: observer) + let contextStore = ObserverContextStore() + let interceptor = ObserverInterceptor(observer: observer, contextStore: contextStore) XCTAssertNotNil(interceptor.id) XCTAssertFalse(observer.willSendRequestCalled) } - func testObserverWeakReference() { - var observer: MockObserver? = MockObserver() - let interceptor = ObserverInterceptor(observer: observer!) - - // Release the observer - observer = nil + func testPairedInterceptorCreation() { + let observer = MockObserver() + let contextStore = ObserverContextStore() + let beforeInterceptor = ObserverInterceptor(observer: observer, contextStore: contextStore) + let afterInterceptor = ObserverInterceptor(observer: observer, contextStore: contextStore) - // Interceptor should not crash when observer is deallocated - // (notifyFailure should safely do nothing) - interceptor.notifyFailure(NSError(domain: "Test", code: 0)) + // Both should have unique IDs + XCTAssertNotEqual(beforeInterceptor.id, afterInterceptor.id) } // MARK: - Multiple Observers Tests @@ -72,8 +71,9 @@ final class GraphQLNetworkObserverTests: XCTestCase { let observer2 = MockObserver() let observer3 = MockObserver() - let interceptors = [observer1, observer2, observer3].map { - ObserverInterceptor(observer: $0) + let interceptors = [observer1, observer2, observer3].map { (observer: MockObserver) in + let contextStore = ObserverContextStore() + return ObserverInterceptor(observer: observer, contextStore: contextStore) } XCTAssertEqual(interceptors.count, 3) @@ -206,4 +206,41 @@ final class GraphQLNetworkObserverTests: XCTestCase { XCTAssertEqual(observer.lastRequest?.value(forHTTPHeaderField: "X-Default-Header"), "default-value") XCTAssertEqual(observer.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer context-token") } + + // MARK: - Context Store Tests + + func testContextStoreAndRetrieve() async { + let store = ObserverContextStore() + + await store.store("test-context", for: "request-1") + let retrieved = await store.retrieve(for: "request-1") + + XCTAssertEqual(retrieved, "test-context") + } + + func testContextStoreRetrieveRemovesContext() async { + let store = ObserverContextStore() + + await store.store("test-context", for: "request-1") + _ = await store.retrieve(for: "request-1") + let secondRetrieve = await store.retrieve(for: "request-1") + + XCTAssertNil(secondRetrieve) + } + + func testContextStoreMultipleContexts() async { + let store = ObserverContextStore() + + await store.store(1, for: "request-1") + await store.store(2, for: "request-2") + await store.store(3, for: "request-3") + + let context2 = await store.retrieve(for: "request-2") + let context1 = await store.retrieve(for: "request-1") + let context3 = await store.retrieve(for: "request-3") + + XCTAssertEqual(context1, 1) + XCTAssertEqual(context2, 2) + XCTAssertEqual(context3, 3) + } } From 0513226dd2d1d86cbda7c92702b953024cdd6b6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Tue, 13 Jan 2026 09:09:22 +0100 Subject: [PATCH 09/14] refactor: Move interceptor implementations to dedicated files Extracted `NetworkInterceptorProvider` and `RequestHeaderInterceptor` into their own files and relocated `ObserverInterceptor` to a new `Interceptors` directory for better modularity. --- Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift | 71 ------------------- .../NetworkInterceptorProvider.swift | 45 ++++++++++++ .../ObserverInterceptor.swift | 0 .../RequestHeaderInterceptor.swift | 27 +++++++ 4 files changed, 72 insertions(+), 71 deletions(-) create mode 100644 Sources/GraphQLAPIKit/Interceptors/NetworkInterceptorProvider.swift rename Sources/GraphQLAPIKit/{Observers => Interceptors}/ObserverInterceptor.swift (100%) create mode 100644 Sources/GraphQLAPIKit/Interceptors/RequestHeaderInterceptor.swift diff --git a/Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift b/Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift index 5468863..4dad1cf 100644 --- a/Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift +++ b/Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift @@ -116,74 +116,3 @@ public final class GraphQLAPIAdapter: GraphQLAPIAdapterProtocol { } } } - -// MARK: - Network Interceptor Provider - -private struct NetworkInterceptorProvider: InterceptorProvider { - private let client: URLSessionClient - private let defaultHeaders: [String: String] - private let pairOfObserverInterceptors: [(before: ApolloInterceptor, after: ApolloInterceptor)] - - init( - client: URLSessionClient, - defaultHeaders: [String: String], - networkObservers: [any GraphQLNetworkObserver] - ) { - self.client = client - self.defaultHeaders = defaultHeaders - // Create interceptor pairs with shared context stores - self.pairOfObserverInterceptors = networkObservers.map { Self.makePair(of: $0) } - - } - - func interceptors(for operation: Operation) -> [ApolloInterceptor] { - // Headers first, then before-observers, then network fetch, then after-observers - [ - RequestHeaderInterceptor(defaultHeaders: defaultHeaders), - ] - + pairOfObserverInterceptors.map(\.before) // Before network - captures timing - + [ - MaxRetryInterceptor(), - NetworkFetchInterceptor(client: client) - ] - + pairOfObserverInterceptors.map(\.after) // After network - captures response - + [ - ResponseCodeInterceptor(), - MultipartResponseParsingInterceptor(), - JSONResponseParsingInterceptor() - ] - } - - static private func makePair(of observer: T) -> (before: ApolloInterceptor, after: ApolloInterceptor) { - let contextStore = ObserverContextStore() - let beforeInterceptor = ObserverInterceptor(observer: observer, contextStore: contextStore) - let afterInterceptor = ObserverInterceptor(observer: observer, contextStore: contextStore) - return (before: beforeInterceptor, after: afterInterceptor) - } -} - -// MARK: - Request Header Interceptor - -private struct RequestHeaderInterceptor: ApolloInterceptor { - var id: String = UUID().uuidString - - private let defaultHeaders: [String: String] - - init(defaultHeaders: [String: String]) { - self.defaultHeaders = defaultHeaders - } - - func interceptAsync( - chain: RequestChain, - request: HTTPRequest, - response: HTTPResponse?, - completion: @escaping (Result, Error>) -> Void - ) { - defaultHeaders.forEach { request.addHeader(name: $0.key, value: $0.value) } - if let additionalHeaders = request.context as? RequestHeaders { - additionalHeaders.additionalHeaders.forEach { request.addHeader(name: $0.key, value: $0.value) } - } - - chain.proceedAsync(request: request, response: response, interceptor: self, completion: completion) - } -} diff --git a/Sources/GraphQLAPIKit/Interceptors/NetworkInterceptorProvider.swift b/Sources/GraphQLAPIKit/Interceptors/NetworkInterceptorProvider.swift new file mode 100644 index 0000000..4b55acf --- /dev/null +++ b/Sources/GraphQLAPIKit/Interceptors/NetworkInterceptorProvider.swift @@ -0,0 +1,45 @@ +import Apollo +import ApolloAPI +import Foundation + +struct NetworkInterceptorProvider: InterceptorProvider { + private let client: URLSessionClient + private let defaultHeaders: [String: String] + private let pairOfObserverInterceptors: [(before: ApolloInterceptor, after: ApolloInterceptor)] + + init( + client: URLSessionClient, + defaultHeaders: [String: String], + networkObservers: [any GraphQLNetworkObserver] + ) { + self.client = client + self.defaultHeaders = defaultHeaders + // Create interceptor pairs with shared context stores + self.pairOfObserverInterceptors = networkObservers.map { Self.makePair(of: $0) } + } + + func interceptors(for operation: Operation) -> [ApolloInterceptor] { + // Headers first, then before-observers, then network fetch, then after-observers + [ + RequestHeaderInterceptor(defaultHeaders: defaultHeaders), + ] + + pairOfObserverInterceptors.map(\.before) // Before network - captures timing + + [ + MaxRetryInterceptor(), + NetworkFetchInterceptor(client: client) + ] + + pairOfObserverInterceptors.map(\.after) // After network - captures response + + [ + ResponseCodeInterceptor(), + MultipartResponseParsingInterceptor(), + JSONResponseParsingInterceptor() + ] + } + + static private func makePair(of observer: T) -> (before: ApolloInterceptor, after: ApolloInterceptor) { + let contextStore = ObserverContextStore() + let beforeInterceptor = ObserverInterceptor(observer: observer, contextStore: contextStore) + let afterInterceptor = ObserverInterceptor(observer: observer, contextStore: contextStore) + return (before: beforeInterceptor, after: afterInterceptor) + } +} diff --git a/Sources/GraphQLAPIKit/Observers/ObserverInterceptor.swift b/Sources/GraphQLAPIKit/Interceptors/ObserverInterceptor.swift similarity index 100% rename from Sources/GraphQLAPIKit/Observers/ObserverInterceptor.swift rename to Sources/GraphQLAPIKit/Interceptors/ObserverInterceptor.swift diff --git a/Sources/GraphQLAPIKit/Interceptors/RequestHeaderInterceptor.swift b/Sources/GraphQLAPIKit/Interceptors/RequestHeaderInterceptor.swift new file mode 100644 index 0000000..39d8134 --- /dev/null +++ b/Sources/GraphQLAPIKit/Interceptors/RequestHeaderInterceptor.swift @@ -0,0 +1,27 @@ +import Apollo +import ApolloAPI +import Foundation + +struct RequestHeaderInterceptor: ApolloInterceptor { + let id: String = UUID().uuidString + + private let defaultHeaders: [String: String] + + init(defaultHeaders: [String: String]) { + self.defaultHeaders = defaultHeaders + } + + func interceptAsync( + chain: RequestChain, + request: HTTPRequest, + response: HTTPResponse?, + completion: @escaping (Result, Error>) -> Void + ) { + defaultHeaders.forEach { request.addHeader(name: $0.key, value: $0.value) } + if let additionalHeaders = request.context as? RequestHeaders { + additionalHeaders.additionalHeaders.forEach { request.addHeader(name: $0.key, value: $0.value) } + } + + chain.proceedAsync(request: request, response: response, interceptor: self, completion: completion) + } +} From 349241e6b87327a8d012da6121f97422fc0b69e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Tue, 13 Jan 2026 09:16:40 +0100 Subject: [PATCH 10/14] refactor(ObserverInterceptor): Convert to struct for improved concurrency and simplified memory management The ObserverInterceptor has been refactored from a class to a struct. This change enhances Sendable conformance, eliminates potential reference cycles, and removes the need for explicit weak self captures within asynchronous blocks. --- .../Interceptors/ObserverInterceptor.swift | 16 +++++++--------- .../Observers/GraphQLNetworkObserver.swift | 2 -- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/Sources/GraphQLAPIKit/Interceptors/ObserverInterceptor.swift b/Sources/GraphQLAPIKit/Interceptors/ObserverInterceptor.swift index f0cf4a8..a670f9a 100644 --- a/Sources/GraphQLAPIKit/Interceptors/ObserverInterceptor.swift +++ b/Sources/GraphQLAPIKit/Interceptors/ObserverInterceptor.swift @@ -6,7 +6,7 @@ import Foundation /// - One BEFORE NetworkFetchInterceptor (captures request timing) /// - One AFTER NetworkFetchInterceptor (captures response) /// Both instances share state via the contextStore actor. -final class ObserverInterceptor: ApolloInterceptor, @unchecked Sendable { +struct ObserverInterceptor: ApolloInterceptor { let id = UUID().uuidString private let observer: Observer @@ -38,12 +38,11 @@ final class ObserverInterceptor: ApolloInterce } } else { // AFTER network fetch - retrieve context and call didReceiveResponse - Task { [weak self] in - guard let self, - let context = await contextStore.retrieve(for: requestId) else { + Task { + guard let context = await contextStore.retrieve(for: requestId) else { return } - self.observer.didReceiveResponse( + observer.didReceiveResponse( for: urlRequest, response: response?.httpResponse, data: response?.rawData, @@ -53,15 +52,14 @@ final class ObserverInterceptor: ApolloInterce } // Wrap completion to handle errors - let wrappedCompletion: (Result, Error>) -> Void = { [weak self] result in + let wrappedCompletion: (Result, Error>) -> Void = { result in if case .failure(let error) = result { Task { - guard let self, - let context = await self.contextStore.retrieve(for: requestId) else { + guard let context = await contextStore.retrieve(for: requestId) else { completion(result) return } - self.observer.didFail(request: urlRequest, error: error, context: context) + observer.didFail(request: urlRequest, error: error, context: context) completion(result) } return diff --git a/Sources/GraphQLAPIKit/Observers/GraphQLNetworkObserver.swift b/Sources/GraphQLAPIKit/Observers/GraphQLNetworkObserver.swift index 6d90d49..a56d5ef 100644 --- a/Sources/GraphQLAPIKit/Observers/GraphQLNetworkObserver.swift +++ b/Sources/GraphQLAPIKit/Observers/GraphQLNetworkObserver.swift @@ -5,8 +5,6 @@ import Foundation /// Implement this protocol to add logging, analytics, or request tracking to GraphQL operations. /// Observers are passive - they cannot modify requests or responses, only observe them. /// -/// This protocol matches FTAPIKit's `NetworkObserver` pattern exactly, using `URLRequest` directly. -/// /// ## Context Lifecycle /// The `Context` associated type allows passing correlation data (request ID, start time, etc.) /// through the request lifecycle: From 2dde75dc98782dbb27c7360f7d787a1bcde07ecf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Tue, 13 Jan 2026 09:18:31 +0100 Subject: [PATCH 11/14] test(GraphQLNetworkObserver): Refactor and consolidate tests for network observer Streamlines `ObserverInterceptor` creation, enhances `NetworkObserver` protocol conformance, and unifies `ObserverContextStore` operations into robust test cases. --- .../GraphQLNetworkObserverTests.swift | 191 +++--------------- 1 file changed, 28 insertions(+), 163 deletions(-) diff --git a/Tests/GraphQLAPIKitTests/GraphQLNetworkObserverTests.swift b/Tests/GraphQLAPIKitTests/GraphQLNetworkObserverTests.swift index 5690435..95dda85 100644 --- a/Tests/GraphQLAPIKitTests/GraphQLNetworkObserverTests.swift +++ b/Tests/GraphQLAPIKitTests/GraphQLNetworkObserverTests.swift @@ -48,54 +48,33 @@ final class GraphQLNetworkObserverTests: XCTestCase { func testObserverInterceptorCreation() { let observer = MockObserver() let contextStore = ObserverContextStore() - let interceptor = ObserverInterceptor(observer: observer, contextStore: contextStore) - XCTAssertNotNil(interceptor.id) - XCTAssertFalse(observer.willSendRequestCalled) - } - - func testPairedInterceptorCreation() { - let observer = MockObserver() - let contextStore = ObserverContextStore() - let beforeInterceptor = ObserverInterceptor(observer: observer, contextStore: contextStore) - let afterInterceptor = ObserverInterceptor(observer: observer, contextStore: contextStore) - - // Both should have unique IDs - XCTAssertNotEqual(beforeInterceptor.id, afterInterceptor.id) - } - - // MARK: - Multiple Observers Tests - - func testMultipleInterceptorsCreation() { - let observer1 = MockObserver() - let observer2 = MockObserver() - let observer3 = MockObserver() - - let interceptors = [observer1, observer2, observer3].map { (observer: MockObserver) in - let contextStore = ObserverContextStore() - return ObserverInterceptor(observer: observer, contextStore: contextStore) - } + let interceptor1 = ObserverInterceptor(observer: observer, contextStore: contextStore) + let interceptor2 = ObserverInterceptor(observer: observer, contextStore: contextStore) - XCTAssertEqual(interceptors.count, 3) - - // All should have unique IDs - let ids = Set(interceptors.map { $0.id }) - XCTAssertEqual(ids.count, 3) + XCTAssertNotNil(interceptor1.id) + XCTAssertNotNil(interceptor2.id) + XCTAssertNotEqual(interceptor1.id, interceptor2.id) + XCTAssertFalse(observer.willSendRequestCalled) } - // MARK: - Protocol Conformance Tests - func testProtocolMethodSignatures() { - // This test verifies the protocol matches FTAPIKit's NetworkObserver pattern let observer = MockObserver() + let url = URL(string: "https://api.example.com/graphql")! - // Create a sample URLRequest - let request = URLRequest(url: URL(string: "https://example.com/graphql")!) + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer test-token", forHTTPHeaderField: "Authorization") // Test willSendRequest returns Context let context = observer.willSendRequest(request) XCTAssertTrue(observer.willSendRequestCalled) XCTAssertNotNil(context.requestId) + XCTAssertEqual(observer.lastRequest?.url, url) + XCTAssertEqual(observer.lastRequest?.httpMethod, "POST") + XCTAssertEqual(observer.lastRequest?.value(forHTTPHeaderField: "Content-Type"), "application/json") + XCTAssertEqual(observer.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer test-token") // Test didReceiveResponse observer.didReceiveResponse(for: request, response: nil, data: nil, context: context) @@ -106,141 +85,27 @@ final class GraphQLNetworkObserverTests: XCTestCase { XCTAssertTrue(observer.didFailCalled) } - func testURLRequestPassedCorrectly() { - let observer = MockObserver() - let url = URL(string: "https://api.example.com/graphql")! - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - _ = observer.willSendRequest(request) - - XCTAssertEqual(observer.lastRequest?.url, url) - XCTAssertEqual(observer.lastRequest?.httpMethod, "POST") - XCTAssertEqual(observer.lastRequest?.value(forHTTPHeaderField: "Content-Type"), "application/json") - } - - // MARK: - Header Capture Tests - - func testObserverReceivesHeadersFromURLRequest() { - // Test that when willSendRequest is called with a URLRequest containing headers, - // the observer can access all those headers - let observer = MockObserver() - let url = URL(string: "https://api.example.com/graphql")! - - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue("Bearer test-token", forHTTPHeaderField: "Authorization") - request.setValue("custom-value", forHTTPHeaderField: "X-Custom-Header") - request.setValue("en-US", forHTTPHeaderField: "Accept-Language") - - _ = observer.willSendRequest(request) - - // Verify observer received the request with all headers - XCTAssertTrue(observer.willSendRequestCalled) - XCTAssertNotNil(observer.lastRequest) - - // Check all custom headers are present - XCTAssertEqual(observer.lastRequest?.value(forHTTPHeaderField: "Content-Type"), "application/json") - XCTAssertEqual(observer.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer test-token") - XCTAssertEqual(observer.lastRequest?.value(forHTTPHeaderField: "X-Custom-Header"), "custom-value") - XCTAssertEqual(observer.lastRequest?.value(forHTTPHeaderField: "Accept-Language"), "en-US") - } - - func testMultipleObserversReceiveSameHeaders() { - // Test that multiple observers all receive the same URLRequest with headers - let observer1 = MockObserver() - let observer2 = MockObserver() - let observer3 = MockObserver() - - let url = URL(string: "https://api.example.com/graphql")! - var request = URLRequest(url: url) - request.setValue("Bearer shared-token", forHTTPHeaderField: "Authorization") - request.setValue("api-key-123", forHTTPHeaderField: "X-API-Key") - - // Simulate what happens when multiple interceptors call willSendRequest - _ = observer1.willSendRequest(request) - _ = observer2.willSendRequest(request) - _ = observer3.willSendRequest(request) - - // All observers should have received the same headers - for observer in [observer1, observer2, observer3] { - XCTAssertTrue(observer.willSendRequestCalled) - XCTAssertEqual(observer.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer shared-token") - XCTAssertEqual(observer.lastRequest?.value(forHTTPHeaderField: "X-API-Key"), "api-key-123") - } - } - - // MARK: - Interceptor Chain Order Tests - - func testInterceptorChainOrderPlacesObserversAfterHeaders() { - // This test verifies that the interceptor chain is ordered correctly: - // RequestHeaderInterceptor -> ObserverInterceptors -> NetworkFetchInterceptor... - // - // We verify this by checking that when HTTPRequest.addHeader is called (by RequestHeaderInterceptor), - // and then toURLRequest() is called (by ObserverInterceptor), the headers are present. - - let url = URL(string: "https://api.example.com/graphql")! - - // Create a mock HTTPRequest-like structure to simulate the flow - var additionalHeaders: [String: String] = [:] - - // Step 1: RequestHeaderInterceptor adds default headers - additionalHeaders["X-Default-Header"] = "default-value" - - // Step 2: RequestHeaderInterceptor adds context headers (additionalHeaders from RequestHeaders) - additionalHeaders["Authorization"] = "Bearer context-token" - - // Step 3: Create URLRequest (simulating what happens in ObserverInterceptor) - var urlRequest = URLRequest(url: url) - for (name, value) in additionalHeaders { - urlRequest.addValue(value, forHTTPHeaderField: name) - } - - // Step 4: Observer receives the request - let observer = MockObserver() - _ = observer.willSendRequest(urlRequest) - - // Verify observer sees both default and context headers - XCTAssertEqual(observer.lastRequest?.value(forHTTPHeaderField: "X-Default-Header"), "default-value") - XCTAssertEqual(observer.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer context-token") - } - // MARK: - Context Store Tests - func testContextStoreAndRetrieve() async { + func testContextStoreOperations() async { let store = ObserverContextStore() - await store.store("test-context", for: "request-1") - let retrieved = await store.retrieve(for: "request-1") - - XCTAssertEqual(retrieved, "test-context") - } - - func testContextStoreRetrieveRemovesContext() async { - let store = ObserverContextStore() - - await store.store("test-context", for: "request-1") - _ = await store.retrieve(for: "request-1") - let secondRetrieve = await store.retrieve(for: "request-1") - - XCTAssertNil(secondRetrieve) - } - - func testContextStoreMultipleContexts() async { - let store = ObserverContextStore() - - await store.store(1, for: "request-1") - await store.store(2, for: "request-2") - await store.store(3, for: "request-3") + // Test store and retrieve + await store.store("context-1", for: "request-1") + await store.store("context-2", for: "request-2") + await store.store("context-3", for: "request-3") + // Retrieve in different order let context2 = await store.retrieve(for: "request-2") let context1 = await store.retrieve(for: "request-1") let context3 = await store.retrieve(for: "request-3") - XCTAssertEqual(context1, 1) - XCTAssertEqual(context2, 2) - XCTAssertEqual(context3, 3) + XCTAssertEqual(context1, "context-1") + XCTAssertEqual(context2, "context-2") + XCTAssertEqual(context3, "context-3") + + // Verify retrieve removes context + let secondRetrieve = await store.retrieve(for: "request-1") + XCTAssertNil(secondRetrieve) } } From e38a9cc333feade14de71f75dc98b2fecef817d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Tue, 13 Jan 2026 09:37:14 +0100 Subject: [PATCH 12/14] refactor(concurrency): Refactor ObserverContextStore to use OSAllocatedUnfairLock The ObserverContextStore was converted from an actor to a final class, utilizing OSAllocatedUnfairLock for thread-safe synchronous access. This simplifies the ObserverInterceptor by removing unnecessary Task wrappers and associated await calls. The minimum macOS deployment target was also updated to v13. --- .../Interceptors/ObserverInterceptor.swift | 19 ++++--------------- .../Observers/ObserverContextStore.swift | 11 ++++++----- .../GraphQLNetworkObserverTests.swift | 16 ++++++++-------- 3 files changed, 18 insertions(+), 28 deletions(-) diff --git a/Sources/GraphQLAPIKit/Interceptors/ObserverInterceptor.swift b/Sources/GraphQLAPIKit/Interceptors/ObserverInterceptor.swift index a670f9a..68581c0 100644 --- a/Sources/GraphQLAPIKit/Interceptors/ObserverInterceptor.swift +++ b/Sources/GraphQLAPIKit/Interceptors/ObserverInterceptor.swift @@ -31,17 +31,12 @@ struct ObserverInterceptor: ApolloInterceptor let requestId = urlRequest.hashValue.description if response == nil { - // BEFORE network fetch - call willSendRequest and store context + // BEFORE network fetch - call willSendRequest and store context synchronously let context = observer.willSendRequest(urlRequest) - Task { - await contextStore.store(context, for: requestId) - } + contextStore.store(context, for: requestId) } else { // AFTER network fetch - retrieve context and call didReceiveResponse - Task { - guard let context = await contextStore.retrieve(for: requestId) else { - return - } + if let context = contextStore.retrieve(for: requestId) { observer.didReceiveResponse( for: urlRequest, response: response?.httpResponse, @@ -54,15 +49,9 @@ struct ObserverInterceptor: ApolloInterceptor // Wrap completion to handle errors let wrappedCompletion: (Result, Error>) -> Void = { result in if case .failure(let error) = result { - Task { - guard let context = await contextStore.retrieve(for: requestId) else { - completion(result) - return - } + if let context = contextStore.retrieve(for: requestId) { observer.didFail(request: urlRequest, error: error, context: context) - completion(result) } - return } completion(result) } diff --git a/Sources/GraphQLAPIKit/Observers/ObserverContextStore.swift b/Sources/GraphQLAPIKit/Observers/ObserverContextStore.swift index 83fac13..072f305 100644 --- a/Sources/GraphQLAPIKit/Observers/ObserverContextStore.swift +++ b/Sources/GraphQLAPIKit/Observers/ObserverContextStore.swift @@ -1,15 +1,16 @@ import Foundation +import os -/// Actor that stores observer contexts keyed by request identifier. +/// Thread-safe store for observer contexts keyed by request identifier. /// Enables two interceptor instances to share state across the interceptor chain. -actor ObserverContextStore { - private var contexts: [String: Context] = [:] +final class ObserverContextStore: Sendable { + private let state = OSAllocatedUnfairLock(initialState: [String: Context]()) func store(_ context: Context, for requestId: String) { - contexts[requestId] = context + state.withLock { $0[requestId] = context } } func retrieve(for requestId: String) -> Context? { - contexts.removeValue(forKey: requestId) + state.withLock { $0.removeValue(forKey: requestId) } } } diff --git a/Tests/GraphQLAPIKitTests/GraphQLNetworkObserverTests.swift b/Tests/GraphQLAPIKitTests/GraphQLNetworkObserverTests.swift index 95dda85..70fbba6 100644 --- a/Tests/GraphQLAPIKitTests/GraphQLNetworkObserverTests.swift +++ b/Tests/GraphQLAPIKitTests/GraphQLNetworkObserverTests.swift @@ -87,25 +87,25 @@ final class GraphQLNetworkObserverTests: XCTestCase { // MARK: - Context Store Tests - func testContextStoreOperations() async { + func testContextStoreOperations() { let store = ObserverContextStore() // Test store and retrieve - await store.store("context-1", for: "request-1") - await store.store("context-2", for: "request-2") - await store.store("context-3", for: "request-3") + store.store("context-1", for: "request-1") + store.store("context-2", for: "request-2") + store.store("context-3", for: "request-3") // Retrieve in different order - let context2 = await store.retrieve(for: "request-2") - let context1 = await store.retrieve(for: "request-1") - let context3 = await store.retrieve(for: "request-3") + let context2 = store.retrieve(for: "request-2") + let context1 = store.retrieve(for: "request-1") + let context3 = store.retrieve(for: "request-3") XCTAssertEqual(context1, "context-1") XCTAssertEqual(context2, "context-2") XCTAssertEqual(context3, "context-3") // Verify retrieve removes context - let secondRetrieve = await store.retrieve(for: "request-1") + let secondRetrieve = store.retrieve(for: "request-1") XCTAssertNil(secondRetrieve) } } From 8b63e6ae74b6fe3b2737205cf855889fcfa5c8bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Tue, 13 Jan 2026 09:44:27 +0100 Subject: [PATCH 13/14] fix(observer-interceptor): Ensure stable request ID generation Replaced `urlRequest.hashValue.description` with `ObjectIdentifier(request).debugDescription` for stable request tracking by observers, addressing potential inconsistencies with hash values. --- Sources/GraphQLAPIKit/Interceptors/ObserverInterceptor.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/GraphQLAPIKit/Interceptors/ObserverInterceptor.swift b/Sources/GraphQLAPIKit/Interceptors/ObserverInterceptor.swift index 68581c0..81da4c3 100644 --- a/Sources/GraphQLAPIKit/Interceptors/ObserverInterceptor.swift +++ b/Sources/GraphQLAPIKit/Interceptors/ObserverInterceptor.swift @@ -28,7 +28,7 @@ struct ObserverInterceptor: ApolloInterceptor return } - let requestId = urlRequest.hashValue.description + let requestId = ObjectIdentifier(request).debugDescription if response == nil { // BEFORE network fetch - call willSendRequest and store context synchronously From f6c8707094fece81294ed8fba571dca4da0489a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Tue, 13 Jan 2026 14:00:55 +0100 Subject: [PATCH 14/14] feat(GraphQLAPIAdapter): Add variadic generic initializer for network observers This introduces a new initializer for `GraphQLAPIAdapter` that leverages Swift 5.9's variadic generics, allowing multiple `GraphQLNetworkObserver` instances to be passed directly without being wrapped in an array. This improves API ergonomics and simplifies usage. The `swift-tools-version` has been updated to 5.9 accordingly. --- Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift | 28 ++++++++++++++++++- .../GraphQLAPIAdapterIntegrationTests.swift | 10 +++---- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift b/Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift index 4dad1cf..0b372c7 100644 --- a/Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift +++ b/Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift @@ -38,11 +38,37 @@ public protocol GraphQLAPIAdapterProtocol: AnyObject { public final class GraphQLAPIAdapter: GraphQLAPIAdapterProtocol { private let apollo: ApolloClientProtocol + public init( + url: URL, + urlSessionConfiguration: URLSessionConfiguration = .default, + defaultHeaders: [String: String] = [:], + networkObservers: repeat each Observer + ) { + var observers: [any GraphQLNetworkObserver] = [] + repeat observers.append(each networkObservers) + + let provider = NetworkInterceptorProvider( + client: URLSessionClient(sessionConfiguration: urlSessionConfiguration), + defaultHeaders: defaultHeaders, + networkObservers: observers + ) + + let networkTransport = RequestChainNetworkTransport( + interceptorProvider: provider, + endpointURL: url + ) + + self.apollo = ApolloClient( + networkTransport: networkTransport, + store: ApolloStore() + ) + } + public init( url: URL, urlSessionConfiguration: URLSessionConfiguration = .default, defaultHeaders: [String: String] = [:], - networkObservers: [any GraphQLNetworkObserver] = [] + networkObservers: [any GraphQLNetworkObserver] ) { let provider = NetworkInterceptorProvider( client: URLSessionClient(sessionConfiguration: urlSessionConfiguration), diff --git a/Tests/GraphQLAPIKitTests/GraphQLAPIAdapterIntegrationTests.swift b/Tests/GraphQLAPIKitTests/GraphQLAPIAdapterIntegrationTests.swift index e9f7aef..5635d4a 100644 --- a/Tests/GraphQLAPIKitTests/GraphQLAPIAdapterIntegrationTests.swift +++ b/Tests/GraphQLAPIKitTests/GraphQLAPIAdapterIntegrationTests.swift @@ -175,7 +175,7 @@ final class GraphQLAPIAdapterIntegrationTests: XCTestCase { url: URL(string: "https://api.example.com/graphql")!, urlSessionConfiguration: mockSessionConfiguration(), defaultHeaders: defaultHeaders, - networkObservers: [observer] + networkObservers: observer ) _ = adapter.fetch(query: MockQuery(), context: nil, queue: .main) { _ in @@ -210,7 +210,7 @@ final class GraphQLAPIAdapterIntegrationTests: XCTestCase { url: URL(string: "https://api.example.com/graphql")!, urlSessionConfiguration: mockSessionConfiguration(), defaultHeaders: [:], - networkObservers: [observer] + networkObservers: observer ) _ = adapter.fetch(query: MockQuery(), context: contextHeaders, queue: .main) { _ in @@ -249,7 +249,7 @@ final class GraphQLAPIAdapterIntegrationTests: XCTestCase { url: URL(string: "https://api.example.com/graphql")!, urlSessionConfiguration: mockSessionConfiguration(), defaultHeaders: defaultHeaders, - networkObservers: [observer] + networkObservers: observer ) _ = adapter.fetch(query: MockQuery(), context: contextHeaders, queue: .main) { _ in @@ -285,7 +285,7 @@ final class GraphQLAPIAdapterIntegrationTests: XCTestCase { url: URL(string: "https://api.example.com/graphql")!, urlSessionConfiguration: mockSessionConfiguration(), defaultHeaders: defaultHeaders, - networkObservers: [observer1, observer2, observer3] + networkObservers: observer1, observer2, observer3 ) _ = adapter.fetch(query: MockQuery(), context: nil, queue: .main) { _ in @@ -322,7 +322,7 @@ final class GraphQLAPIAdapterIntegrationTests: XCTestCase { url: URL(string: "https://api.example.com/graphql")!, urlSessionConfiguration: mockSessionConfiguration(), defaultHeaders: [:], - networkObservers: [observer] + networkObservers: observer ) _ = adapter.fetch(query: MockQuery(), context: nil, queue: .main) { _ in