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 diff --git a/Package.swift b/Package.swift index aed40bf..f01238e 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/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), . 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..bfecea3 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 { @@ -61,60 +107,148 @@ 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)) + } + + guard let data = response.data else { + assertionFailure("No data received") + throw GraphQLAPIAdapterError.unhandled( + NSError( + domain: "GraphQLAPIKit", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "No data received"] + ) + ) + } - if let errors = response.errors, !errors.isEmpty { - throw GraphQLAPIAdapterError(error: ApolloError(errors: errors)) + return data } + } - guard let data = response.data else { - assertionFailure("No data received") - throw GraphQLAPIAdapterError.unhandled( - NSError( - domain: "GraphQLAPIKit", - code: -1, - userInfo: [NSLocalizedDescriptionKey: "No data received"] - ) + public func perform( + mutation: Mutation, + configuration: GraphQLRequestConfiguration = GraphQLRequestConfiguration() + ) async throws -> Mutation.Data where Mutation.ResponseFormat == SingleResponseFormat { + try await RequestHeadersContext.$headers.withValue(configuration.headers) { + let config = RequestConfiguration(writeResultsToCache: false) + + let response = try await apollo.perform( + mutation: mutation, + requestConfiguration: config ) + + 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"] + ) + ) + } + + return data } + } + + // MARK: - Incremental/Deferred Response + + public func fetch( + query: Query, + configuration: GraphQLRequestConfiguration = GraphQLRequestConfiguration() + ) throws -> AsyncThrowingStream where Query.ResponseFormat == IncrementalDeferredResponseFormat { + try RequestHeadersContext.$headers.withValue(configuration.headers) { + let config = RequestConfiguration(writeResultsToCache: false) + + let apolloStream = try apollo.fetch( + query: query, + cachePolicy: .networkOnly, + requestConfiguration: config + ) - return data + return transformStream(apolloStream) + } } 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) + ) throws -> AsyncThrowingStream where Mutation.ResponseFormat == IncrementalDeferredResponseFormat { + try RequestHeadersContext.$headers.withValue(configuration.headers) { + let config = RequestConfiguration(writeResultsToCache: false) - let response = try await apollo.perform( - mutation: mutation, - requestConfiguration: config - ) + let apolloStream = try apollo.perform( + mutation: mutation, + requestConfiguration: config + ) - if let errors = response.errors, !errors.isEmpty { - throw GraphQLAPIAdapterError(error: ApolloError(errors: errors)) + return transformStream(apolloStream) } + } - guard let data = response.data else { - assertionFailure("No data received") - throw GraphQLAPIAdapterError.unhandled( - NSError( - domain: "GraphQLAPIKit", - code: -1, - userInfo: [NSLocalizedDescriptionKey: "No data received"] - ) + // MARK: - Subscriptions + + public func subscribe( + subscription: Subscription, + configuration: GraphQLSubscriptionConfiguration = GraphQLSubscriptionConfiguration() + ) async throws -> AsyncThrowingStream { + try await RequestHeadersContext.$headers.withValue(configuration.headers) { + let config = RequestConfiguration(writeResultsToCache: false) + + let apolloStream = try await apollo.subscribe( + subscription: subscription, + requestConfiguration: config ) + + return transformStream(apolloStream) } + } - return data + // 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() + } + } } } 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/GraphQLAPIAdapterErrorTests.swift b/Tests/GraphQLAPIKitTests/GraphQLAPIAdapterErrorTests.swift index 7e591c3..e8aa9d2 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() { diff --git a/Tests/GraphQLAPIKitTests/GraphQLAPIAdapterIntegrationTests.swift b/Tests/GraphQLAPIKitTests/GraphQLAPIAdapterIntegrationTests.swift index 72c63cb..02fa8c5 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: - Mock Request Headers @@ -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") + } + } } diff --git a/Tests/GraphQLAPIKitTests/GraphQLAPIAdapterTests.swift b/Tests/GraphQLAPIKitTests/GraphQLAPIAdapterTests.swift index fac6d0b..4057226 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 @@ -40,5 +41,4 @@ final class GraphQLAPIAdapterTests: XCTestCase { let adapter = GraphQLAPIAdapter(configuration: configuration) 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 0e764ee..b7f3e75 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 } @@ -47,6 +49,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)