Skip to content

Commit 4ae59ac

Browse files
authored
Merge pull request #8 from futuredapp/feature/network-observers
feat: Add GraphQL network observers matching FTAPIKit pattern
2 parents 4b46c01 + f6c8707 commit 4ae59ac

9 files changed

Lines changed: 687 additions & 52 deletions

Package.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ let package = Package(
2929
dependencies: [
3030
.product(name: "Apollo", package: "apollo-ios"),
3131
]
32+
),
33+
.testTarget(
34+
name: "GraphQLAPIKitTests",
35+
dependencies: [
36+
"GraphQLAPIKit"
37+
]
3238
)
3339
]
3440
)

Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift

Lines changed: 33 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ public protocol GraphQLAPIAdapterProtocol: AnyObject {
1818
queue: DispatchQueue,
1919
resultHandler: @escaping (Result<Query.Data, GraphQLAPIAdapterError>) -> Void
2020
) -> Cancellable
21-
21+
2222
/// Performs a mutation by sending it to the server.
2323
///
2424
/// - Parameters:
@@ -38,14 +38,42 @@ public protocol GraphQLAPIAdapterProtocol: AnyObject {
3838
public final class GraphQLAPIAdapter: GraphQLAPIAdapterProtocol {
3939
private let apollo: ApolloClientProtocol
4040

41+
public init<each Observer: GraphQLNetworkObserver>(
42+
url: URL,
43+
urlSessionConfiguration: URLSessionConfiguration = .default,
44+
defaultHeaders: [String: String] = [:],
45+
networkObservers: repeat each Observer
46+
) {
47+
var observers: [any GraphQLNetworkObserver] = []
48+
repeat observers.append(each networkObservers)
49+
50+
let provider = NetworkInterceptorProvider(
51+
client: URLSessionClient(sessionConfiguration: urlSessionConfiguration),
52+
defaultHeaders: defaultHeaders,
53+
networkObservers: observers
54+
)
55+
56+
let networkTransport = RequestChainNetworkTransport(
57+
interceptorProvider: provider,
58+
endpointURL: url
59+
)
60+
61+
self.apollo = ApolloClient(
62+
networkTransport: networkTransport,
63+
store: ApolloStore()
64+
)
65+
}
66+
4167
public init(
4268
url: URL,
4369
urlSessionConfiguration: URLSessionConfiguration = .default,
44-
defaultHeaders: [String: String] = [:]
70+
defaultHeaders: [String: String] = [:],
71+
networkObservers: [any GraphQLNetworkObserver]
4572
) {
4673
let provider = NetworkInterceptorProvider(
4774
client: URLSessionClient(sessionConfiguration: urlSessionConfiguration),
48-
defaultHeaders: defaultHeaders
75+
defaultHeaders: defaultHeaders,
76+
networkObservers: networkObservers
4977
)
5078

5179
let networkTransport = RequestChainNetworkTransport(
@@ -64,7 +92,7 @@ public final class GraphQLAPIAdapter: GraphQLAPIAdapterProtocol {
6492
context: RequestHeaders?,
6593
queue: DispatchQueue,
6694
resultHandler: @escaping (Result<Query.Data, GraphQLAPIAdapterError>) -> Void
67-
) -> Cancellable where Query : GraphQLQuery {
95+
) -> Cancellable where Query: GraphQLQuery {
6896
apollo.fetch(
6997
query: query,
7098
cachePolicy: .fetchIgnoringCacheCompletely,
@@ -92,7 +120,7 @@ public final class GraphQLAPIAdapter: GraphQLAPIAdapterProtocol {
92120
context: RequestHeaders?,
93121
queue: DispatchQueue,
94122
resultHandler: @escaping (Result<Mutation.Data, GraphQLAPIAdapterError>) -> Void
95-
) -> Cancellable where Mutation : GraphQLMutation {
123+
) -> Cancellable where Mutation: GraphQLMutation {
96124
apollo.perform(
97125
mutation: mutation,
98126
publishResultToStore: false,
@@ -114,50 +142,3 @@ public final class GraphQLAPIAdapter: GraphQLAPIAdapterProtocol {
114142
}
115143
}
116144
}
117-
118-
private struct NetworkInterceptorProvider: InterceptorProvider {
119-
private let client: URLSessionClient
120-
private let defaultHeaders: [String: String]
121-
122-
init(client: URLSessionClient, defaultHeaders: [String: String]) {
123-
self.client = client
124-
self.defaultHeaders = defaultHeaders
125-
}
126-
127-
func interceptors<Operation: GraphQLOperation>(for operation: Operation) -> [ApolloInterceptor] {
128-
[
129-
RequestHeaderInterceptor(defaultHeaders: defaultHeaders),
130-
MaxRetryInterceptor(),
131-
NetworkFetchInterceptor(client: self.client),
132-
ResponseCodeInterceptor(),
133-
MultipartResponseParsingInterceptor(),
134-
JSONResponseParsingInterceptor()
135-
]
136-
}
137-
}
138-
139-
private struct RequestHeaderInterceptor: ApolloInterceptor {
140-
var id: String = UUID().uuidString
141-
142-
private let defaultHeaders: [String: String]
143-
144-
init(defaultHeaders: [String: String]) {
145-
self.defaultHeaders = defaultHeaders
146-
}
147-
148-
func interceptAsync<Operation: GraphQLOperation>(
149-
chain: RequestChain,
150-
request: HTTPRequest<Operation>,
151-
response: HTTPResponse<Operation>?,
152-
completion: @escaping (Result<GraphQLResult<Operation.Data>, Error>) -> Void
153-
) {
154-
defaultHeaders.forEach { request.addHeader(name: $0.key, value: $0.value) }
155-
if let additionalHeaders = request.context as? RequestHeaders {
156-
additionalHeaders.additionalHeaders.forEach { request.addHeader(name: $0.key, value: $0.value) }
157-
}
158-
159-
chain.proceedAsync(request: request, response: response, interceptor: self, completion: completion)
160-
}
161-
}
162-
163-
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import Apollo
2+
import ApolloAPI
3+
import Foundation
4+
5+
struct NetworkInterceptorProvider: InterceptorProvider {
6+
private let client: URLSessionClient
7+
private let defaultHeaders: [String: String]
8+
private let pairOfObserverInterceptors: [(before: ApolloInterceptor, after: ApolloInterceptor)]
9+
10+
init(
11+
client: URLSessionClient,
12+
defaultHeaders: [String: String],
13+
networkObservers: [any GraphQLNetworkObserver]
14+
) {
15+
self.client = client
16+
self.defaultHeaders = defaultHeaders
17+
// Create interceptor pairs with shared context stores
18+
self.pairOfObserverInterceptors = networkObservers.map { Self.makePair(of: $0) }
19+
}
20+
21+
func interceptors<Operation: GraphQLOperation>(for operation: Operation) -> [ApolloInterceptor] {
22+
// Headers first, then before-observers, then network fetch, then after-observers
23+
[
24+
RequestHeaderInterceptor(defaultHeaders: defaultHeaders),
25+
]
26+
+ pairOfObserverInterceptors.map(\.before) // Before network - captures timing
27+
+ [
28+
MaxRetryInterceptor(),
29+
NetworkFetchInterceptor(client: client)
30+
]
31+
+ pairOfObserverInterceptors.map(\.after) // After network - captures response
32+
+ [
33+
ResponseCodeInterceptor(),
34+
MultipartResponseParsingInterceptor(),
35+
JSONResponseParsingInterceptor()
36+
]
37+
}
38+
39+
static private func makePair<T: GraphQLNetworkObserver>(of observer: T) -> (before: ApolloInterceptor, after: ApolloInterceptor) {
40+
let contextStore = ObserverContextStore<T.Context>()
41+
let beforeInterceptor = ObserverInterceptor(observer: observer, contextStore: contextStore)
42+
let afterInterceptor = ObserverInterceptor(observer: observer, contextStore: contextStore)
43+
return (before: beforeInterceptor, after: afterInterceptor)
44+
}
45+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import Apollo
2+
import ApolloAPI
3+
import Foundation
4+
5+
/// Interceptor that observes network requests. Place TWO instances in chain:
6+
/// - One BEFORE NetworkFetchInterceptor (captures request timing)
7+
/// - One AFTER NetworkFetchInterceptor (captures response)
8+
/// Both instances share state via the contextStore actor.
9+
struct ObserverInterceptor<Observer: GraphQLNetworkObserver>: ApolloInterceptor {
10+
let id = UUID().uuidString
11+
12+
private let observer: Observer
13+
private let contextStore: ObserverContextStore<Observer.Context>
14+
15+
init(observer: Observer, contextStore: ObserverContextStore<Observer.Context>) {
16+
self.observer = observer
17+
self.contextStore = contextStore
18+
}
19+
20+
func interceptAsync<Operation: GraphQLOperation>(
21+
chain: RequestChain,
22+
request: HTTPRequest<Operation>,
23+
response: HTTPResponse<Operation>?,
24+
completion: @escaping (Result<GraphQLResult<Operation.Data>, Error>) -> Void
25+
) {
26+
guard let urlRequest = try? request.toURLRequest() else {
27+
chain.proceedAsync(request: request, response: response, interceptor: self, completion: completion)
28+
return
29+
}
30+
31+
let requestId = ObjectIdentifier(request).debugDescription
32+
33+
if response == nil {
34+
// BEFORE network fetch - call willSendRequest and store context synchronously
35+
let context = observer.willSendRequest(urlRequest)
36+
contextStore.store(context, for: requestId)
37+
} else {
38+
// AFTER network fetch - retrieve context and call didReceiveResponse
39+
if let context = contextStore.retrieve(for: requestId) {
40+
observer.didReceiveResponse(
41+
for: urlRequest,
42+
response: response?.httpResponse,
43+
data: response?.rawData,
44+
context: context
45+
)
46+
}
47+
}
48+
49+
// Wrap completion to handle errors
50+
let wrappedCompletion: (Result<GraphQLResult<Operation.Data>, Error>) -> Void = { result in
51+
if case .failure(let error) = result {
52+
if let context = contextStore.retrieve(for: requestId) {
53+
observer.didFail(request: urlRequest, error: error, context: context)
54+
}
55+
}
56+
completion(result)
57+
}
58+
59+
chain.proceedAsync(request: request, response: response, interceptor: self, completion: wrappedCompletion)
60+
}
61+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import Apollo
2+
import ApolloAPI
3+
import Foundation
4+
5+
struct RequestHeaderInterceptor: ApolloInterceptor {
6+
let id: String = UUID().uuidString
7+
8+
private let defaultHeaders: [String: String]
9+
10+
init(defaultHeaders: [String: String]) {
11+
self.defaultHeaders = defaultHeaders
12+
}
13+
14+
func interceptAsync<Operation: GraphQLOperation>(
15+
chain: RequestChain,
16+
request: HTTPRequest<Operation>,
17+
response: HTTPResponse<Operation>?,
18+
completion: @escaping (Result<GraphQLResult<Operation.Data>, Error>) -> Void
19+
) {
20+
defaultHeaders.forEach { request.addHeader(name: $0.key, value: $0.value) }
21+
if let additionalHeaders = request.context as? RequestHeaders {
22+
additionalHeaders.additionalHeaders.forEach { request.addHeader(name: $0.key, value: $0.value) }
23+
}
24+
25+
chain.proceedAsync(request: request, response: response, interceptor: self, completion: completion)
26+
}
27+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import Foundation
2+
3+
/// Protocol for observing GraphQL network request lifecycle events.
4+
///
5+
/// Implement this protocol to add logging, analytics, or request tracking to GraphQL operations.
6+
/// Observers are passive - they cannot modify requests or responses, only observe them.
7+
///
8+
/// ## Context Lifecycle
9+
/// The `Context` associated type allows passing correlation data (request ID, start time, etc.)
10+
/// through the request lifecycle:
11+
/// 1. `willSendRequest` is called before the request starts and returns a `Context` value
12+
/// 2. `didReceiveResponse` is always called with the raw HTTP response data (useful for debugging)
13+
/// 3. `didFail` is called additionally if the request fails
14+
/// 4. If the observer is deallocated before the request completes, the context is discarded
15+
/// and no completion callback is invoked
16+
///
17+
public protocol GraphQLNetworkObserver: AnyObject, Sendable {
18+
associatedtype Context: Sendable
19+
20+
/// Called immediately before a request is sent.
21+
/// - Parameter request: The URLRequest about to be sent
22+
/// - Returns: Context to be passed to `didReceiveResponse` and optionally `didFail`
23+
func willSendRequest(_ request: URLRequest) -> Context
24+
25+
/// Called when a response is received from the server.
26+
///
27+
/// This is always called with the raw response data, even if processing subsequently fails.
28+
/// This allows observers to inspect the actual response for debugging purposes.
29+
/// - Parameters:
30+
/// - request: The original request
31+
/// - response: The URL response (may be nil if network error occurred before response)
32+
/// - data: Response body data, if any
33+
/// - context: Value returned from `willSendRequest`
34+
func didReceiveResponse(for request: URLRequest, response: URLResponse?, data: Data?, context: Context)
35+
36+
/// Called when a request fails with an error.
37+
///
38+
/// Called after `didReceiveResponse` if processing determines the request failed.
39+
/// - Parameters:
40+
/// - request: The original request
41+
/// - error: The error that occurred
42+
/// - context: Value returned from `willSendRequest`
43+
func didFail(request: URLRequest, error: Error, context: Context)
44+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import Foundation
2+
import os
3+
4+
/// Thread-safe store for observer contexts keyed by request identifier.
5+
/// Enables two interceptor instances to share state across the interceptor chain.
6+
final class ObserverContextStore<Context: Sendable>: Sendable {
7+
private let state = OSAllocatedUnfairLock(initialState: [String: Context]())
8+
9+
func store(_ context: Context, for requestId: String) {
10+
state.withLock { $0[requestId] = context }
11+
}
12+
13+
func retrieve(for requestId: String) -> Context? {
14+
state.withLock { $0.removeValue(forKey: requestId) }
15+
}
16+
}

0 commit comments

Comments
 (0)