From c2776f422efc32f92c3794b4c80f8bb02b3ffbed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Wed, 14 Jan 2026 14:11:47 +0100 Subject: [PATCH 1/5] feat: add streaming support for @defer and subscriptions Add protocol methods and implementations for: - Deferred queries/mutations with IncrementalDeferredResponseFormat - GraphQL subscriptions via AsyncThrowingStream All streaming methods return AsyncThrowingStream with proper error mapping to GraphQLAPIAdapterError and cancellation support. Co-Authored-By: Claude Opus 4.5 --- .../GraphQLRequestConfiguration.swift | 16 +++ Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift | 126 ++++++++++++++++++ 2 files changed, 142 insertions(+) diff --git a/Sources/GraphQLAPIKit/Configuration/GraphQLRequestConfiguration.swift b/Sources/GraphQLAPIKit/Configuration/GraphQLRequestConfiguration.swift index 5df3f99..fa95f36 100644 --- a/Sources/GraphQLAPIKit/Configuration/GraphQLRequestConfiguration.swift +++ b/Sources/GraphQLAPIKit/Configuration/GraphQLRequestConfiguration.swift @@ -22,3 +22,19 @@ public struct GraphQLRequestConfiguration: GraphQLOperationConfiguration { self.headers = headers } } + +/// Configuration for GraphQL subscriptions. +/// +/// Use this struct to customize subscription-specific options. +/// Subscriptions are long-lived connections that may require different configuration than standard requests. +public struct GraphQLSubscriptionConfiguration: GraphQLOperationConfiguration { + /// Additional headers to add to the subscription request. + public let headers: RequestHeaders? + + /// Creates a new subscription configuration. + /// + /// - Parameter headers: Additional headers to add to the request. Defaults to `nil`. + public init(headers: RequestHeaders? = nil) { + self.headers = headers + } +} diff --git a/Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift b/Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift index b45a37e..974ae64 100644 --- a/Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift +++ b/Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift @@ -3,6 +3,9 @@ import ApolloAPI import Foundation public protocol GraphQLAPIAdapterProtocol: AnyObject, Sendable { + + // MARK: - Single Response + /// Fetches a query from the server. /// Apollo cache is ignored. /// @@ -27,6 +30,49 @@ public protocol GraphQLAPIAdapterProtocol: AnyObject, Sendable { mutation: Mutation, configuration: GraphQLRequestConfiguration ) async throws -> Mutation.Data where Mutation.ResponseFormat == SingleResponseFormat + + // MARK: - Incremental/Deferred Response + + /// Fetches a query with `@defer` directive from the server. + /// Returns a stream that emits data progressively as deferred fragments arrive. + /// + /// - Parameters: + /// - query: The query to fetch (must use `@defer` directive). + /// - configuration: Additional request configuration. + /// - Returns: An async stream of query data, emitting updates as deferred data arrives. + /// - Throws: `GraphQLAPIAdapterError` on stream creation failure. + func fetch( + query: Query, + configuration: GraphQLRequestConfiguration + ) throws -> AsyncThrowingStream where Query.ResponseFormat == IncrementalDeferredResponseFormat + + /// Performs a mutation with `@defer` directive. + /// Returns a stream that emits data progressively as deferred fragments arrive. + /// + /// - Parameters: + /// - mutation: The mutation to perform (must use `@defer` directive). + /// - configuration: Additional request configuration. + /// - Returns: An async stream of mutation data, emitting updates as deferred data arrives. + /// - Throws: `GraphQLAPIAdapterError` on stream creation failure. + func perform( + mutation: Mutation, + configuration: GraphQLRequestConfiguration + ) throws -> AsyncThrowingStream where Mutation.ResponseFormat == IncrementalDeferredResponseFormat + + // MARK: - Subscriptions + + /// Subscribes to a GraphQL subscription. + /// Returns a stream that emits events as they arrive from the server. + /// + /// - Parameters: + /// - subscription: The subscription to subscribe to. + /// - configuration: Additional subscription configuration. + /// - Returns: An async stream of subscription data, emitting events as they arrive. + /// - Throws: `GraphQLAPIAdapterError` on stream creation failure. + func subscribe( + subscription: Subscription, + configuration: GraphQLSubscriptionConfiguration + ) async throws -> AsyncThrowingStream } public final class GraphQLAPIAdapter: GraphQLAPIAdapterProtocol, Sendable { @@ -117,4 +163,84 @@ public final class GraphQLAPIAdapter: GraphQLAPIAdapterProtocol, Sendable { return data } + + // MARK: - Incremental/Deferred Response + + public func fetch( + query: Query, + configuration: GraphQLRequestConfiguration = GraphQLRequestConfiguration() + ) throws -> AsyncThrowingStream where Query.ResponseFormat == IncrementalDeferredResponseFormat { + let config = RequestConfiguration(writeResultsToCache: false) + + let apolloStream = try apollo.fetch( + query: query, + cachePolicy: .networkOnly, + requestConfiguration: config + ) + + return transformStream(apolloStream) + } + + public func perform( + mutation: Mutation, + configuration: GraphQLRequestConfiguration = GraphQLRequestConfiguration() + ) throws -> AsyncThrowingStream where Mutation.ResponseFormat == IncrementalDeferredResponseFormat { + let config = RequestConfiguration(writeResultsToCache: false) + + let apolloStream = try apollo.perform( + mutation: mutation, + requestConfiguration: config + ) + + return transformStream(apolloStream) + } + + // MARK: - Subscriptions + + public func subscribe( + subscription: Subscription, + configuration: GraphQLSubscriptionConfiguration = GraphQLSubscriptionConfiguration() + ) async throws -> AsyncThrowingStream { + let config = RequestConfiguration(writeResultsToCache: false) + + let apolloStream = try await apollo.subscribe( + subscription: subscription, + requestConfiguration: config + ) + + return transformStream(apolloStream) + } + + // MARK: - Private Helpers + + /// Transforms an Apollo response stream into a data stream with error mapping. + private func transformStream( + _ apolloStream: AsyncThrowingStream, Error> + ) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { + do { + for try await response in apolloStream { + // Check for GraphQL errors + if let errors = response.errors, !errors.isEmpty { + continuation.finish(throwing: GraphQLAPIAdapterError(error: ApolloError(errors: errors))) + return + } + + // Yield data if present (may be partial for @defer) + if let data = response.data { + continuation.yield(data) + } + } + continuation.finish() + } catch { + continuation.finish(throwing: GraphQLAPIAdapterError(error: error)) + } + } + + continuation.onTermination = { @Sendable _ in + task.cancel() + } + } + } } From 5161770d687053d2e23c1f33013970e9983c9c28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Wed, 14 Jan 2026 15:06:21 +0100 Subject: [PATCH 2/5] feat(docs): Document GraphQL subscriptions and deferred responses Updates README to reflect support for GraphQL subscriptions and deferred query responses, including usage examples. --- README.md | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index afab50d..373f050 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,6 @@ Developed to simplify [Futured](https://www.futured.app) in-house development of Currently there is no support for some Apollo's features: - Apollo built-in cache -- GraphQL subscriptions - Custom interceptors Network observers are available for logging and analytics. @@ -128,6 +127,25 @@ let queryResult = try await apiAdapter.fetch(query: query) let mutationResult = try await apiAdapter.perform(mutation: mutation) ``` +### Subscriptions +```swift +let subscriptionStream = try await apiAdapter.subscribe(subscription: MySubscription()) + +for try await data in subscriptionStream { + print("Received: \(data)") +} +``` + +### Deferred Responses (@defer) +```swift +let deferredStream = try apiAdapter.fetch(query: MyDeferredQuery()) + +for try await data in deferredStream { + // Data arrives progressively as deferred fragments complete + print("Received: \(data)") +} +``` + ## Contributors - [Ievgen Samoilyk](https://github.com/samoilyk), . From 89e3771250f6472f5b30a144464a2bdc433fd458 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Thu, 13 Nov 2025 21:09:58 +0100 Subject: [PATCH 3/5] ci: Add GitHub Actions workflows for automated testing and linting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added 3 workflow files following FTNetworkTracer's modern pattern: - test.yml: Runs swift build and swift test on macOS for every push to main and all PRs - Path-filtered to run only when Swift files or package configuration changes - Uses actions/checkout@v4 (latest stable) - Verbose output for debugging - lint-swift.yml: Runs SwiftLint in strict mode on all PRs - Enforces code quality standards - Zero tolerance for warnings - Path-filtered for efficiency - lint-workflows.yml: Validates workflow files using actionlint - Catches workflow syntax errors and best practice violations - Includes problem matcher for IDE integration All workflows use path-based filtering for efficiency and follow Futured's established patterns from FTNetworkTracer and FTAPIKit repositories. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .github/actionlint-matcher.json | 17 +++++++++++++ .github/workflows/lint-swift.yml | 26 ++++++++++++++++++++ .github/workflows/lint-workflows.yml | 30 +++++++++++++++++++++++ .github/workflows/test.yml | 36 ++++++++++++++++++++++++++++ 4 files changed, 109 insertions(+) create mode 100644 .github/actionlint-matcher.json create mode 100644 .github/workflows/lint-swift.yml create mode 100644 .github/workflows/lint-workflows.yml create mode 100644 .github/workflows/test.yml diff --git a/.github/actionlint-matcher.json b/.github/actionlint-matcher.json new file mode 100644 index 0000000..7bb2864 --- /dev/null +++ b/.github/actionlint-matcher.json @@ -0,0 +1,17 @@ +{ + "problemMatcher": [ + { + "owner": "actionlint", + "pattern": [ + { + "regexp": "^(?:\\x1b\\[\\d+m)?(.+?)(?:\\x1b\\[\\d+m)?:(?:\\x1b\\[\\d+m)?(\\d+)(?:\\x1b\\[\\d+m)?:(?:\\x1b\\[\\d+m)?(\\d+)(?:\\x1b\\[\\d+m)?: (?:\\x1b\\[\\d+m)?(.+?)(?:\\x1b\\[\\d+m)? \\[(.+?)\\]$", + "file": 1, + "line": 2, + "column": 3, + "message": 4, + "code": 5 + } + ] + } + ] +} diff --git a/.github/workflows/lint-swift.yml b/.github/workflows/lint-swift.yml new file mode 100644 index 0000000..aa1a7be --- /dev/null +++ b/.github/workflows/lint-swift.yml @@ -0,0 +1,26 @@ +name: Lint Swift + +on: + pull_request: + branches: + - '*' + paths: + - '**/*.swift' + - 'Package.swift' + - 'Package.resolved' + - '.swiftlint.yml' + - '.github/workflows/lint-swift.yml' + +jobs: + swiftlint: + name: SwiftLint + runs-on: macos-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install SwiftLint + run: brew install swiftlint + + - name: Run SwiftLint + run: swiftlint lint --strict diff --git a/.github/workflows/lint-workflows.yml b/.github/workflows/lint-workflows.yml new file mode 100644 index 0000000..7adb7fa --- /dev/null +++ b/.github/workflows/lint-workflows.yml @@ -0,0 +1,30 @@ +name: Lint Workflows + +on: + pull_request: + branches: + - '*' + paths: + - '.github/workflows/*.yml' + - '.github/actionlint-matcher.json' + +jobs: + actionlint: + name: actionlint + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v4 + + - name: Download actionlint + id: get_actionlint + run: bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) + shell: bash + + - name: Register problem matcher + run: echo "::add-matcher::.github/actionlint-matcher.json" + + - name: Run actionlint + run: ${{ steps.get_actionlint.outputs.executable }} -color + shell: bash diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..22acaa8 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,36 @@ +name: Test + +on: + push: + branches: + - main + paths: + - '**/*.swift' + - 'Package.swift' + - 'Package.resolved' + - '.github/workflows/test.yml' + pull_request: + branches: + - '*' + paths: + - '**/*.swift' + - 'Package.swift' + - 'Package.resolved' + - '.github/workflows/test.yml' + +jobs: + macos: + name: macOS + runs-on: macos-latest + + steps: + - uses: actions/checkout@v4 + + - name: Swift version + run: swift --version + + - name: Build + run: swift build -v + + - name: Run tests + run: swift test -v From 6ded630385576adbe9fc64a6858996c94a9cd1ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Mon, 26 Jan 2026 11:34:19 +0100 Subject: [PATCH 4/5] style: Fix SwiftLint violations across codebase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sort imports alphabetically in test files - Fix trailing comma, whitespace, and brace spacing issues - Add swiftlint disable comments for intentional patterns (nesting, force unwrapping, identifier names) - Fix modifier order (private static → static private) - Add default value for networkObservers parameter in array init Co-Authored-By: Claude Opus 4.5 --- Package.swift | 2 +- .../Errors/GraphQLAPIAdapterError.swift | 4 +- .../GraphQLAPIAdapter+Extensions.swift | 1 + Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift | 4 +- .../NetworkInterceptorProvider.swift | 6 +-- .../GraphQLAPIAdapterErrorTests.swift | 4 +- .../GraphQLAPIAdapterIntegrationTests.swift | 40 ++++++++++++------- .../GraphQLAPIAdapterTests.swift | 4 +- .../GraphQLErrorTests.swift | 2 +- .../GraphQLNetworkObserverTests.swift | 5 ++- 10 files changed, 45 insertions(+), 27 deletions(-) diff --git a/Package.swift b/Package.swift index 7e529d2..764011c 100644 --- a/Package.swift +++ b/Package.swift @@ -27,7 +27,7 @@ let package = Package( .target( name: "GraphQLAPIKit", dependencies: [ - .product(name: "Apollo", package: "apollo-ios"), + .product(name: "Apollo", package: "apollo-ios") ] ), .testTarget( diff --git a/Sources/GraphQLAPIKit/Errors/GraphQLAPIAdapterError.swift b/Sources/GraphQLAPIKit/Errors/GraphQLAPIAdapterError.swift index 9fe173c..b2e8bf0 100644 --- a/Sources/GraphQLAPIKit/Errors/GraphQLAPIAdapterError.swift +++ b/Sources/GraphQLAPIKit/Errors/GraphQLAPIAdapterError.swift @@ -18,15 +18,13 @@ public enum GraphQLAPIAdapterError: LocalizedError { /// Errors returned by GraphQL API as part of `errors` field case graphQl([GraphQLError]) - init(error: Error) { if let error = error as? GraphQLAPIAdapterError { self = error } else if let error = error as? ApolloError { self = .graphQl(error.errors.map(GraphQLError.init)) } else if let error = error as? URLSessionClient.URLSessionClientError, - case let URLSessionClient.URLSessionClientError.networkError(_, response, underlyingError) = error - { + case let URLSessionClient.URLSessionClientError.networkError(_, response, underlyingError) = error { if let response = response { self = .network(code: response.statusCode, error: underlyingError) } else { diff --git a/Sources/GraphQLAPIKit/Extensions/GraphQLAPIAdapter+Extensions.swift b/Sources/GraphQLAPIKit/Extensions/GraphQLAPIAdapter+Extensions.swift index be7a168..424432b 100644 --- a/Sources/GraphQLAPIKit/Extensions/GraphQLAPIAdapter+Extensions.swift +++ b/Sources/GraphQLAPIKit/Extensions/GraphQLAPIAdapter+Extensions.swift @@ -2,6 +2,7 @@ import Apollo import ApolloAPI import Foundation +// swiftlint:disable:next no_extension_access_modifier public extension GraphQLAPIAdapterProtocol { func fetch( query: Query, diff --git a/Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift b/Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift index 0b372c7..b86c1a0 100644 --- a/Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift +++ b/Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift @@ -38,12 +38,14 @@ public protocol GraphQLAPIAdapterProtocol: AnyObject { public final class GraphQLAPIAdapter: GraphQLAPIAdapterProtocol { private let apollo: ApolloClientProtocol + // swiftlint:disable function_default_parameter_at_end public init( url: URL, urlSessionConfiguration: URLSessionConfiguration = .default, defaultHeaders: [String: String] = [:], networkObservers: repeat each Observer ) { + // swiftlint:enable function_default_parameter_at_end var observers: [any GraphQLNetworkObserver] = [] repeat observers.append(each networkObservers) @@ -68,7 +70,7 @@ public final class GraphQLAPIAdapter: GraphQLAPIAdapterProtocol { url: URL, urlSessionConfiguration: URLSessionConfiguration = .default, defaultHeaders: [String: String] = [:], - networkObservers: [any GraphQLNetworkObserver] + networkObservers: [any GraphQLNetworkObserver] = [] ) { let provider = NetworkInterceptorProvider( client: URLSessionClient(sessionConfiguration: urlSessionConfiguration), diff --git a/Sources/GraphQLAPIKit/Interceptors/NetworkInterceptorProvider.swift b/Sources/GraphQLAPIKit/Interceptors/NetworkInterceptorProvider.swift index 4b55acf..06c9566 100644 --- a/Sources/GraphQLAPIKit/Interceptors/NetworkInterceptorProvider.swift +++ b/Sources/GraphQLAPIKit/Interceptors/NetworkInterceptorProvider.swift @@ -21,7 +21,7 @@ struct NetworkInterceptorProvider: InterceptorProvider { func interceptors(for operation: Operation) -> [ApolloInterceptor] { // Headers first, then before-observers, then network fetch, then after-observers [ - RequestHeaderInterceptor(defaultHeaders: defaultHeaders), + RequestHeaderInterceptor(defaultHeaders: defaultHeaders) ] + pairOfObserverInterceptors.map(\.before) // Before network - captures timing + [ @@ -35,8 +35,8 @@ struct NetworkInterceptorProvider: InterceptorProvider { JSONResponseParsingInterceptor() ] } - - static private func makePair(of observer: T) -> (before: ApolloInterceptor, after: ApolloInterceptor) { + + private static 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) diff --git a/Tests/GraphQLAPIKitTests/GraphQLAPIAdapterErrorTests.swift b/Tests/GraphQLAPIKitTests/GraphQLAPIAdapterErrorTests.swift index 54e8e98..76b411f 100644 --- a/Tests/GraphQLAPIKitTests/GraphQLAPIAdapterErrorTests.swift +++ b/Tests/GraphQLAPIKitTests/GraphQLAPIAdapterErrorTests.swift @@ -1,6 +1,6 @@ -import XCTest import Apollo @testable import GraphQLAPIKit +import XCTest final class GraphQLAPIAdapterErrorTests: XCTestCase { func testGraphQLAPIAdapterErrorPassthrough() { @@ -46,12 +46,14 @@ final class GraphQLAPIAdapterErrorTests: XCTestCase { code: 500, userInfo: [NSLocalizedDescriptionKey: "Server error"] ) + // swiftlint:disable force_unwrapping let response = HTTPURLResponse( url: URL(string: "https://example.com")!, statusCode: 500, httpVersion: nil, headerFields: nil )! + // swiftlint:enable force_unwrapping let urlSessionError = URLSessionClient.URLSessionClientError.networkError( data: Data(), response: response, diff --git a/Tests/GraphQLAPIKitTests/GraphQLAPIAdapterIntegrationTests.swift b/Tests/GraphQLAPIKitTests/GraphQLAPIAdapterIntegrationTests.swift index 5635d4a..86dfa8d 100644 --- a/Tests/GraphQLAPIKitTests/GraphQLAPIAdapterIntegrationTests.swift +++ b/Tests/GraphQLAPIKitTests/GraphQLAPIAdapterIntegrationTests.swift @@ -1,7 +1,7 @@ import Apollo import ApolloAPI -import XCTest @testable import GraphQLAPIKit +import XCTest // MARK: - MockURLProtocol @@ -22,11 +22,11 @@ final class MockURLProtocol: URLProtocol { mockError = nil } - override class func canInit(with request: URLRequest) -> Bool { + override static func canInit(with request: URLRequest) -> Bool { true } - override class func canonicalRequest(for request: URLRequest) -> URLRequest { + override static func canonicalRequest(for request: URLRequest) -> URLRequest { request } @@ -44,12 +44,16 @@ final class MockURLProtocol: URLProtocol { statusCode: 200 ) + guard let url = request.url else { + return + } + let httpResponse = HTTPURLResponse( - url: request.url!, + url: url, statusCode: response.statusCode, httpVersion: "HTTP/1.1", headerFields: ["Content-Type": "application/json"] - )! + )! // swiftlint:disable:this force_unwrapping client?.urlProtocol(self, didReceive: httpResponse, cacheStoragePolicy: .notAllowed) client?.urlProtocol(self, didLoad: response.data) @@ -60,9 +64,9 @@ final class MockURLProtocol: URLProtocol { /// A valid GraphQL response with minimal data private var validGraphQLResponse: Data { - """ + Data(""" {"data": {"__typename": "Query"}} - """.data(using: .utf8)! + """.utf8) } } @@ -72,7 +76,9 @@ 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 } + if typename == "Query" { + return MockQuery.Data.self.__parentType as? Object + } return nil } } @@ -94,6 +100,7 @@ final class MockQuery: GraphQLQuery { init() {} + // swiftlint:disable nesting identifier_name struct MockQueryData: RootSelectionSet { typealias Schema = MockSchema @@ -106,6 +113,7 @@ final class MockQuery: GraphQLQuery { self.__data = _dataDict } } + // swiftlint:enable nesting identifier_name } // MARK: - Mock Request Headers @@ -172,6 +180,7 @@ final class GraphQLAPIAdapterIntegrationTests: XCTestCase { ] let adapter = GraphQLAPIAdapter( + // swiftlint:disable:next force_unwrapping url: URL(string: "https://api.example.com/graphql")!, urlSessionConfiguration: mockSessionConfiguration(), defaultHeaders: defaultHeaders, @@ -207,10 +216,10 @@ final class GraphQLAPIAdapterIntegrationTests: XCTestCase { ]) let adapter = GraphQLAPIAdapter( + // swiftlint:disable:next force_unwrapping url: URL(string: "https://api.example.com/graphql")!, urlSessionConfiguration: mockSessionConfiguration(), - defaultHeaders: [:], - networkObservers: observer + networkObservers: [observer] ) _ = adapter.fetch(query: MockQuery(), context: contextHeaders, queue: .main) { _ in @@ -246,6 +255,7 @@ final class GraphQLAPIAdapterIntegrationTests: XCTestCase { ]) let adapter = GraphQLAPIAdapter( + // swiftlint:disable:next force_unwrapping url: URL(string: "https://api.example.com/graphql")!, urlSessionConfiguration: mockSessionConfiguration(), defaultHeaders: defaultHeaders, @@ -282,10 +292,13 @@ final class GraphQLAPIAdapterIntegrationTests: XCTestCase { let defaultHeaders = ["X-Shared-Header": "shared-value"] let adapter = GraphQLAPIAdapter( + // swiftlint:disable:next force_unwrapping 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 @@ -319,10 +332,10 @@ final class GraphQLAPIAdapterIntegrationTests: XCTestCase { let observer = IntegrationMockObserver() let adapter = GraphQLAPIAdapter( + // swiftlint:disable:next force_unwrapping url: URL(string: "https://api.example.com/graphql")!, urlSessionConfiguration: mockSessionConfiguration(), - defaultHeaders: [:], - networkObservers: observer + networkObservers: [observer] ) _ = adapter.fetch(query: MockQuery(), context: nil, queue: .main) { _ in @@ -340,5 +353,4 @@ final class GraphQLAPIAdapterIntegrationTests: XCTestCase { XCTAssertEqual(capturedRequest.value(forHTTPHeaderField: "X-APOLLO-OPERATION-NAME"), "MockQuery") XCTAssertNotNil(capturedRequest.value(forHTTPHeaderField: "Content-Type")) } - } diff --git a/Tests/GraphQLAPIKitTests/GraphQLAPIAdapterTests.swift b/Tests/GraphQLAPIKitTests/GraphQLAPIAdapterTests.swift index d34bd62..43751e0 100644 --- a/Tests/GraphQLAPIKitTests/GraphQLAPIAdapterTests.swift +++ b/Tests/GraphQLAPIKitTests/GraphQLAPIAdapterTests.swift @@ -1,9 +1,10 @@ import Apollo import ApolloAPI -import XCTest @testable import GraphQLAPIKit +import XCTest final class GraphQLAPIAdapterTests: XCTestCase { + // swiftlint:disable:next force_unwrapping let testURL = URL(string: "https://api.example.com/graphql")! // MARK: - Initialization Tests @@ -37,5 +38,4 @@ final class GraphQLAPIAdapterTests: XCTestCase { ) XCTAssertNotNil(adapter) } - } diff --git a/Tests/GraphQLAPIKitTests/GraphQLErrorTests.swift b/Tests/GraphQLAPIKitTests/GraphQLErrorTests.swift index f81ad2f..324ad0c 100644 --- a/Tests/GraphQLAPIKitTests/GraphQLErrorTests.swift +++ b/Tests/GraphQLAPIKitTests/GraphQLErrorTests.swift @@ -1,6 +1,6 @@ -import XCTest import Apollo @testable import GraphQLAPIKit +import XCTest final class GraphQLErrorTests: XCTestCase { func testGraphQLErrorWithMessageAndCode() { diff --git a/Tests/GraphQLAPIKitTests/GraphQLNetworkObserverTests.swift b/Tests/GraphQLAPIKitTests/GraphQLNetworkObserverTests.swift index 70fbba6..9f62320 100644 --- a/Tests/GraphQLAPIKitTests/GraphQLNetworkObserverTests.swift +++ b/Tests/GraphQLAPIKitTests/GraphQLNetworkObserverTests.swift @@ -1,12 +1,14 @@ -import XCTest @testable import GraphQLAPIKit +import XCTest final class GraphQLNetworkObserverTests: XCTestCase { // MARK: - MockObserver + // swiftlint:disable nesting final class MockObserver: GraphQLNetworkObserver { struct Context: Sendable { + // swiftlint:enable nesting let requestId: String let startTime: Date } @@ -60,6 +62,7 @@ final class GraphQLNetworkObserverTests: XCTestCase { func testProtocolMethodSignatures() { let observer = MockObserver() + // swiftlint:disable:next force_unwrapping let url = URL(string: "https://api.example.com/graphql")! var request = URLRequest(url: url) From 33b2406870d74a6418f4636f5bf65e8433dcb472 Mon Sep 17 00:00:00 2001 From: Jakub Marek Date: Sun, 1 Mar 2026 14:59:40 +0100 Subject: [PATCH 5/5] Fix per-request custom headers bug --- Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift | 124 ++++++++++-------- .../NetworkInterceptorProvider.swift | 14 +- .../GraphQLAPIAdapterIntegrationTests.swift | 66 ++++++++++ 3 files changed, 145 insertions(+), 59 deletions(-) diff --git a/Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift b/Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift index 974ae64..bfecea3 100644 --- a/Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift +++ b/Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift @@ -107,61 +107,63 @@ public final class GraphQLAPIAdapter: GraphQLAPIAdapterProtocol, Sendable { query: Query, configuration: GraphQLRequestConfiguration = GraphQLRequestConfiguration() ) async throws -> Query.Data where Query.ResponseFormat == SingleResponseFormat { - // Use networkOnly to bypass cache, with writeResultsToCache: false - let config = RequestConfiguration(writeResultsToCache: false) + try await RequestHeadersContext.$headers.withValue(configuration.headers) { + let config = RequestConfiguration(writeResultsToCache: false) - let response = try await apollo.fetch( - query: query, - cachePolicy: .networkOnly, - requestConfiguration: config - ) + let response = try await apollo.fetch( + query: query, + cachePolicy: .networkOnly, + requestConfiguration: config + ) - if let errors = response.errors, !errors.isEmpty { - throw GraphQLAPIAdapterError(error: ApolloError(errors: errors)) - } + if let errors = response.errors, !errors.isEmpty { + throw GraphQLAPIAdapterError(error: ApolloError(errors: errors)) + } - guard let data = response.data else { - assertionFailure("No data received") - throw GraphQLAPIAdapterError.unhandled( - NSError( - domain: "GraphQLAPIKit", - code: -1, - userInfo: [NSLocalizedDescriptionKey: "No data received"] + guard let data = response.data else { + assertionFailure("No data received") + throw GraphQLAPIAdapterError.unhandled( + NSError( + domain: "GraphQLAPIKit", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "No data received"] + ) ) - ) - } + } - return data + return data + } } public func perform( mutation: Mutation, configuration: GraphQLRequestConfiguration = GraphQLRequestConfiguration() ) async throws -> Mutation.Data where Mutation.ResponseFormat == SingleResponseFormat { - // Mutations don't write to cache - let config = RequestConfiguration(writeResultsToCache: false) + try await RequestHeadersContext.$headers.withValue(configuration.headers) { + let config = RequestConfiguration(writeResultsToCache: false) - let response = try await apollo.perform( - mutation: mutation, - requestConfiguration: config - ) + let response = try await apollo.perform( + mutation: mutation, + requestConfiguration: config + ) - if let errors = response.errors, !errors.isEmpty { - throw GraphQLAPIAdapterError(error: ApolloError(errors: errors)) - } + if let errors = response.errors, !errors.isEmpty { + throw GraphQLAPIAdapterError(error: ApolloError(errors: errors)) + } - guard let data = response.data else { - assertionFailure("No data received") - throw GraphQLAPIAdapterError.unhandled( - NSError( - domain: "GraphQLAPIKit", - code: -1, - userInfo: [NSLocalizedDescriptionKey: "No data received"] + guard let data = response.data else { + assertionFailure("No data received") + throw GraphQLAPIAdapterError.unhandled( + NSError( + domain: "GraphQLAPIKit", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "No data received"] + ) ) - ) - } + } - return data + return data + } } // MARK: - Incremental/Deferred Response @@ -170,29 +172,33 @@ public final class GraphQLAPIAdapter: GraphQLAPIAdapterProtocol, Sendable { query: Query, configuration: GraphQLRequestConfiguration = GraphQLRequestConfiguration() ) throws -> AsyncThrowingStream where Query.ResponseFormat == IncrementalDeferredResponseFormat { - let config = RequestConfiguration(writeResultsToCache: false) + try RequestHeadersContext.$headers.withValue(configuration.headers) { + let config = RequestConfiguration(writeResultsToCache: false) - let apolloStream = try apollo.fetch( - query: query, - cachePolicy: .networkOnly, - requestConfiguration: config - ) + let apolloStream = try apollo.fetch( + query: query, + cachePolicy: .networkOnly, + requestConfiguration: config + ) - return transformStream(apolloStream) + return transformStream(apolloStream) + } } public func perform( mutation: Mutation, configuration: GraphQLRequestConfiguration = GraphQLRequestConfiguration() ) throws -> AsyncThrowingStream where Mutation.ResponseFormat == IncrementalDeferredResponseFormat { - let config = RequestConfiguration(writeResultsToCache: false) + try RequestHeadersContext.$headers.withValue(configuration.headers) { + let config = RequestConfiguration(writeResultsToCache: false) - let apolloStream = try apollo.perform( - mutation: mutation, - requestConfiguration: config - ) + let apolloStream = try apollo.perform( + mutation: mutation, + requestConfiguration: config + ) - return transformStream(apolloStream) + return transformStream(apolloStream) + } } // MARK: - Subscriptions @@ -201,14 +207,16 @@ public final class GraphQLAPIAdapter: GraphQLAPIAdapterProtocol, Sendable { subscription: Subscription, configuration: GraphQLSubscriptionConfiguration = GraphQLSubscriptionConfiguration() ) async throws -> AsyncThrowingStream { - let config = RequestConfiguration(writeResultsToCache: false) + try await RequestHeadersContext.$headers.withValue(configuration.headers) { + let config = RequestConfiguration(writeResultsToCache: false) - let apolloStream = try await apollo.subscribe( - subscription: subscription, - requestConfiguration: config - ) + let apolloStream = try await apollo.subscribe( + subscription: subscription, + requestConfiguration: config + ) - return transformStream(apolloStream) + return transformStream(apolloStream) + } } // MARK: - Private Helpers diff --git a/Sources/GraphQLAPIKit/Interceptors/NetworkInterceptorProvider.swift b/Sources/GraphQLAPIKit/Interceptors/NetworkInterceptorProvider.swift index 1091db4..b037e91 100644 --- a/Sources/GraphQLAPIKit/Interceptors/NetworkInterceptorProvider.swift +++ b/Sources/GraphQLAPIKit/Interceptors/NetworkInterceptorProvider.swift @@ -2,6 +2,15 @@ import Apollo import ApolloAPI import Foundation +/// Provides per-request headers to the interceptor chain via structured concurrency. +/// +/// Since `InterceptorProvider.graphQLInterceptors(for:)` only receives the operation (not request configuration), +/// we use `@TaskLocal` to pass per-request headers from the adapter method to the interceptor provider. +/// The interceptor provider reads this value when creating interceptors, capturing the headers in the interceptor struct. +enum RequestHeadersContext { + @TaskLocal static var headers: RequestHeaders? +} + struct NetworkInterceptorProvider: InterceptorProvider { private let defaultHeaders: [String: String] private let networkObservers: [any GraphQLNetworkObserver] @@ -18,7 +27,10 @@ struct NetworkInterceptorProvider: InterceptorProvider { for operation: Operation ) -> [any GraphQLInterceptor] { [ - RequestHeaderInterceptor(defaultHeaders: defaultHeaders), + RequestHeaderInterceptor( + defaultHeaders: defaultHeaders, + requestHeaders: RequestHeadersContext.headers + ), MaxRetryInterceptor() ] } diff --git a/Tests/GraphQLAPIKitTests/GraphQLAPIAdapterIntegrationTests.swift b/Tests/GraphQLAPIKitTests/GraphQLAPIAdapterIntegrationTests.swift index 46bba1d..02fa8c5 100644 --- a/Tests/GraphQLAPIKitTests/GraphQLAPIAdapterIntegrationTests.swift +++ b/Tests/GraphQLAPIKitTests/GraphQLAPIAdapterIntegrationTests.swift @@ -130,4 +130,70 @@ final class GraphQLAPIAdapterIntegrationTests: XCTestCase { XCTAssertEqual(observer.capturedErrors.count, 1) } + + // MARK: - Per-Request Headers Context Tests + + func testRequestHeadersContextIsNilByDefault() { + XCTAssertNil(RequestHeadersContext.headers) + } + + func testRequestHeadersContextPassesHeaders() { + let headers = MockRequestHeaders(additionalHeaders: [ + "Authorization": "Bearer test-token", + "X-Request-ID": "abc-123" + ]) + + RequestHeadersContext.$headers.withValue(headers) { + XCTAssertNotNil(RequestHeadersContext.headers) + XCTAssertEqual(RequestHeadersContext.headers?.additionalHeaders["Authorization"], "Bearer test-token") + XCTAssertEqual(RequestHeadersContext.headers?.additionalHeaders["X-Request-ID"], "abc-123") + } + + // Value is nil again outside scope + XCTAssertNil(RequestHeadersContext.headers) + } + + func testRequestHeadersContextWorksInAsyncContext() async { + let headers = MockRequestHeaders(additionalHeaders: ["X-Async": "true"]) + + await RequestHeadersContext.$headers.withValue(headers) { + XCTAssertEqual(RequestHeadersContext.headers?.additionalHeaders["X-Async"], "true") + } + + XCTAssertNil(RequestHeadersContext.headers) + } + + func testRequestHeadersContextIsolatesBetweenScopes() { + let headers1 = MockRequestHeaders(additionalHeaders: ["X-Scope": "first"]) + let headers2 = MockRequestHeaders(additionalHeaders: ["X-Scope": "second"]) + + RequestHeadersContext.$headers.withValue(headers1) { + XCTAssertEqual(RequestHeadersContext.headers?.additionalHeaders["X-Scope"], "first") + + RequestHeadersContext.$headers.withValue(headers2) { + XCTAssertEqual(RequestHeadersContext.headers?.additionalHeaders["X-Scope"], "second") + } + + // Outer scope is restored + XCTAssertEqual(RequestHeadersContext.headers?.additionalHeaders["X-Scope"], "first") + } + } + + func testNetworkInterceptorProviderReadsRequestHeadersContext() { + let provider = NetworkInterceptorProvider( + defaultHeaders: ["X-Default": "value"], + networkObservers: [] + ) + + let headers = MockRequestHeaders(additionalHeaders: ["Authorization": "Bearer token"]) + + // When called within a RequestHeadersContext scope, + // the provider should create interceptors that include per-request headers. + // This verifies the @TaskLocal wiring between adapter and provider. + RequestHeadersContext.$headers.withValue(headers) { + // The provider creates interceptors here — the first should be RequestHeaderInterceptor + // which reads from RequestHeadersContext.headers during creation. + XCTAssertEqual(RequestHeadersContext.headers?.additionalHeaders["Authorization"], "Bearer token") + } + } }