Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -7,7 +7,7 @@ let package = Package(
name: "GraphQLAPIKit",
platforms: [
.iOS(.v16),
.macOS(.v10_14)
.macOS(.v13)
],
products: [
.library(
Expand All @@ -29,6 +29,12 @@ let package = Package(
dependencies: [
.product(name: "Apollo", package: "apollo-ios"),
]
),
.testTarget(
name: "GraphQLAPIKitTests",
dependencies: [
"GraphQLAPIKit"
]
)
]
)
85 changes: 33 additions & 52 deletions Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public protocol GraphQLAPIAdapterProtocol: AnyObject {
queue: DispatchQueue,
resultHandler: @escaping (Result<Query.Data, GraphQLAPIAdapterError>) -> Void
) -> Cancellable

/// Performs a mutation by sending it to the server.
///
/// - Parameters:
Expand All @@ -38,14 +38,42 @@ public protocol GraphQLAPIAdapterProtocol: AnyObject {
public final class GraphQLAPIAdapter: GraphQLAPIAdapterProtocol {
private let apollo: ApolloClientProtocol

public init<each Observer: GraphQLNetworkObserver>(
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] = [:]
defaultHeaders: [String: String] = [:],
networkObservers: [any GraphQLNetworkObserver]
) {
let provider = NetworkInterceptorProvider(
client: URLSessionClient(sessionConfiguration: urlSessionConfiguration),
defaultHeaders: defaultHeaders
defaultHeaders: defaultHeaders,
networkObservers: networkObservers
)

let networkTransport = RequestChainNetworkTransport(
Expand All @@ -64,7 +92,7 @@ public final class GraphQLAPIAdapter: GraphQLAPIAdapterProtocol {
context: RequestHeaders?,
queue: DispatchQueue,
resultHandler: @escaping (Result<Query.Data, GraphQLAPIAdapterError>) -> Void
) -> Cancellable where Query : GraphQLQuery {
) -> Cancellable where Query: GraphQLQuery {
apollo.fetch(
query: query,
cachePolicy: .fetchIgnoringCacheCompletely,
Expand Down Expand Up @@ -92,7 +120,7 @@ public final class GraphQLAPIAdapter: GraphQLAPIAdapterProtocol {
context: RequestHeaders?,
queue: DispatchQueue,
resultHandler: @escaping (Result<Mutation.Data, GraphQLAPIAdapterError>) -> Void
) -> Cancellable where Mutation : GraphQLMutation {
) -> Cancellable where Mutation: GraphQLMutation {
apollo.perform(
mutation: mutation,
publishResultToStore: false,
Expand All @@ -114,50 +142,3 @@ public final class GraphQLAPIAdapter: GraphQLAPIAdapterProtocol {
}
}
}

private struct NetworkInterceptorProvider: InterceptorProvider {
private let client: URLSessionClient
private let defaultHeaders: [String: String]

init(client: URLSessionClient, defaultHeaders: [String: String]) {
self.client = client
self.defaultHeaders = defaultHeaders
}

func interceptors<Operation: GraphQLOperation>(for operation: Operation) -> [ApolloInterceptor] {
[
RequestHeaderInterceptor(defaultHeaders: defaultHeaders),
MaxRetryInterceptor(),
NetworkFetchInterceptor(client: self.client),
ResponseCodeInterceptor(),
MultipartResponseParsingInterceptor(),
JSONResponseParsingInterceptor()
]
}
}

private struct RequestHeaderInterceptor: ApolloInterceptor {
var id: String = UUID().uuidString

private let defaultHeaders: [String: String]

init(defaultHeaders: [String: String]) {
self.defaultHeaders = defaultHeaders
}

func interceptAsync<Operation: GraphQLOperation>(
chain: RequestChain,
request: HTTPRequest<Operation>,
response: HTTPResponse<Operation>?,
completion: @escaping (Result<GraphQLResult<Operation.Data>, 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)
}
}


Original file line number Diff line number Diff line change
@@ -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<Operation: GraphQLOperation>(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<T: GraphQLNetworkObserver>(of observer: T) -> (before: ApolloInterceptor, after: ApolloInterceptor) {
let contextStore = ObserverContextStore<T.Context>()
let beforeInterceptor = ObserverInterceptor(observer: observer, contextStore: contextStore)
let afterInterceptor = ObserverInterceptor(observer: observer, contextStore: contextStore)
return (before: beforeInterceptor, after: afterInterceptor)
}
}
61 changes: 61 additions & 0 deletions Sources/GraphQLAPIKit/Interceptors/ObserverInterceptor.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import Apollo
import ApolloAPI
import Foundation

/// 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.
struct ObserverInterceptor<Observer: GraphQLNetworkObserver>: ApolloInterceptor {
let id = UUID().uuidString

private let observer: Observer
private let contextStore: ObserverContextStore<Observer.Context>

init(observer: Observer, contextStore: ObserverContextStore<Observer.Context>) {
self.observer = observer
self.contextStore = contextStore
}

func interceptAsync<Operation: GraphQLOperation>(
chain: RequestChain,
request: HTTPRequest<Operation>,
response: HTTPResponse<Operation>?,
completion: @escaping (Result<GraphQLResult<Operation.Data>, Error>) -> Void
) {
guard let urlRequest = try? request.toURLRequest() else {
chain.proceedAsync(request: request, response: response, interceptor: self, completion: completion)
return
}

let requestId = ObjectIdentifier(request).debugDescription

if response == nil {
// BEFORE network fetch - call willSendRequest and store context synchronously
let context = observer.willSendRequest(urlRequest)
contextStore.store(context, for: requestId)
} else {
// AFTER network fetch - retrieve context and call didReceiveResponse
if let context = contextStore.retrieve(for: requestId) {
observer.didReceiveResponse(
for: urlRequest,
response: response?.httpResponse,
data: response?.rawData,
context: context
)
}
}

// Wrap completion to handle errors
let wrappedCompletion: (Result<GraphQLResult<Operation.Data>, Error>) -> Void = { result in
if case .failure(let error) = result {
if let context = contextStore.retrieve(for: requestId) {
observer.didFail(request: urlRequest, error: error, context: context)
}
}
completion(result)
}

chain.proceedAsync(request: request, response: response, interceptor: self, completion: wrappedCompletion)
}
}
27 changes: 27 additions & 0 deletions Sources/GraphQLAPIKit/Interceptors/RequestHeaderInterceptor.swift
Original file line number Diff line number Diff line change
@@ -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<Operation: GraphQLOperation>(
chain: RequestChain,
request: HTTPRequest<Operation>,
response: HTTPResponse<Operation>?,
completion: @escaping (Result<GraphQLResult<Operation.Data>, 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)
}
}
44 changes: 44 additions & 0 deletions Sources/GraphQLAPIKit/Observers/GraphQLNetworkObserver.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import Foundation

/// 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 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 request fails
/// 4. If the observer is deallocated before the request completes, the context is discarded
/// and no completion callback is invoked
///
public protocol GraphQLNetworkObserver: AnyObject, Sendable {
associatedtype Context: Sendable

/// 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(_ request: URLRequest) -> Context

/// Called when a response is received from the server.
///
/// 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:
/// - request: The original request
/// - 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: URLResponse?, data: Data?, context: Context)

/// Called when a request fails with an error.
///
/// Called after `didReceiveResponse` if processing determines the request failed.
/// - Parameters:
/// - request: The original request
/// - error: The error that occurred
/// - context: Value returned from `willSendRequest`
func didFail(request: URLRequest, error: Error, context: Context)
}
16 changes: 16 additions & 0 deletions Sources/GraphQLAPIKit/Observers/ObserverContextStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import Foundation
import os

/// Thread-safe store for observer contexts keyed by request identifier.
/// Enables two interceptor instances to share state across the interceptor chain.
final class ObserverContextStore<Context: Sendable>: Sendable {
private let state = OSAllocatedUnfairLock(initialState: [String: Context]())

func store(_ context: Context, for requestId: String) {
state.withLock { $0[requestId] = context }
}

func retrieve(for requestId: String) -> Context? {
state.withLock { $0.removeValue(forKey: requestId) }
}
}
Loading