Skip to content

Commit adbf70d

Browse files
authored
[MBL-2690] Async/Await Apollo Wrapper (#2621)
1 parent 05021ce commit adbf70d

File tree

4 files changed

+340
-0
lines changed

4 files changed

+340
-0
lines changed

Kickstarter.xcodeproj/project.pbxproj

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -583,6 +583,8 @@
583583
6059151C2E1590D90030BB90 /* onboarding-flow-welcome-es.json in Resources */ = {isa = PBXBuildFile; fileRef = 605914F22E1590D90030BB90 /* onboarding-flow-welcome-es.json */; };
584584
6059151E2E15913A0030BB90 /* LocalizedLottieFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6059151D2E15913A0030BB90 /* LocalizedLottieFile.swift */; };
585585
605915212E15914C0030BB90 /* LocalizedLottieFileTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6059151F2E15914C0030BB90 /* LocalizedLottieFileTests.swift */; };
586+
6061009A2E71F6EB008CA69B /* AsyncApolloClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 606100992E71F6EB008CA69B /* AsyncApolloClient.swift */; };
587+
606100B72E7211B0008CA69B /* AsyncApolloClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 606100B52E7211B0008CA69B /* AsyncApolloClientTests.swift */; };
586588
606754BD28CF91D60033CD5E /* FacebookCore in Frameworks */ = {isa = PBXBuildFile; productRef = 606754BC28CF91D60033CD5E /* FacebookCore */; };
587589
606754BF28CF91DD0033CD5E /* FacebookLogin in Frameworks */ = {isa = PBXBuildFile; productRef = 606754BE28CF91DD0033CD5E /* FacebookLogin */; };
588590
6067BCE9293E49AC0036ABB1 /* FacebookResetPasswordViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6067BCE7293E48140036ABB1 /* FacebookResetPasswordViewController.swift */; };
@@ -652,6 +654,7 @@
652654
60F03AC12D8AFCE300DB6C01 /* SimilarProjectsCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60F03AC02D8AFCE300DB6C01 /* SimilarProjectsCollectionViewCell.swift */; };
653655
60F03AC42D8AFD6100DB6C01 /* SimilarProjectsCollectionViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60F03AC32D8AFD6100DB6C01 /* SimilarProjectsCollectionViewDataSource.swift */; };
654656
60F03AC62D8B0C1600DB6C01 /* SimilarProjectsLoadingCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60F03AC52D8B0C1600DB6C01 /* SimilarProjectsLoadingCollectionViewCell.swift */; };
657+
60F7FAB32E7323CB004B3177 /* MockApolloClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60F7FAB12E7323CB004B3177 /* MockApolloClient.swift */; };
655658
701160D4291ECB9F0095BF24 /* LoadingBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 701160D2291ECB250095BF24 /* LoadingBarButtonItem.swift */; };
656659
70495690299D53ED00B273DF /* SnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = 7049568F299D53ED00B273DF /* SnapshotTesting */; };
657660
7061848B29BE4CD8008F9941 /* MessageBannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7061848829BE4C11008F9941 /* MessageBannerView.swift */; };
@@ -2360,6 +2363,8 @@
23602363
605914F42E1590D90030BB90 /* onboarding-flow-welcome-ja.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "onboarding-flow-welcome-ja.json"; sourceTree = "<group>"; };
23612364
6059151D2E15913A0030BB90 /* LocalizedLottieFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedLottieFile.swift; sourceTree = "<group>"; };
23622365
6059151F2E15914C0030BB90 /* LocalizedLottieFileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedLottieFileTests.swift; sourceTree = "<group>"; };
2366+
606100992E71F6EB008CA69B /* AsyncApolloClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncApolloClient.swift; sourceTree = "<group>"; };
2367+
606100B52E7211B0008CA69B /* AsyncApolloClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncApolloClientTests.swift; sourceTree = "<group>"; };
23632368
6067BCE7293E48140036ABB1 /* FacebookResetPasswordViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FacebookResetPasswordViewController.swift; sourceTree = "<group>"; };
23642369
6067BCEA293E49CB0036ABB1 /* FacebookResetPasswordViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FacebookResetPasswordViewModel.swift; sourceTree = "<group>"; };
23652370
6067BCEF293FC10E0036ABB1 /* FacebookResetPasswordViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FacebookResetPasswordViewModelTests.swift; sourceTree = "<group>"; };
@@ -2402,6 +2407,7 @@
24022407
60F03AC02D8AFCE300DB6C01 /* SimilarProjectsCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimilarProjectsCollectionViewCell.swift; sourceTree = "<group>"; };
24032408
60F03AC32D8AFD6100DB6C01 /* SimilarProjectsCollectionViewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimilarProjectsCollectionViewDataSource.swift; sourceTree = "<group>"; };
24042409
60F03AC52D8B0C1600DB6C01 /* SimilarProjectsLoadingCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimilarProjectsLoadingCollectionViewCell.swift; sourceTree = "<group>"; };
2410+
60F7FAB12E7323CB004B3177 /* MockApolloClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockApolloClient.swift; sourceTree = "<group>"; };
24052411
701160D2291ECB250095BF24 /* LoadingBarButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingBarButtonItem.swift; sourceTree = "<group>"; };
24062412
7061848829BE4C11008F9941 /* MessageBannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageBannerView.swift; sourceTree = "<group>"; };
24072413
7061848C29BE577B008F9941 /* MessageBannerViewViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageBannerViewViewModel.swift; sourceTree = "<group>"; };
@@ -7201,6 +7207,9 @@
72017207
isa = PBXGroup;
72027208
children = (
72037209
D01587531EEB2DE4006E7684 /* Info.plist */,
7210+
606100992E71F6EB008CA69B /* AsyncApolloClient.swift */,
7211+
606100B52E7211B0008CA69B /* AsyncApolloClientTests.swift */,
7212+
60F7FAB12E7323CB004B3177 /* MockApolloClient.swift */,
72047213
D08CD1BF21910C97009F89F0 /* GraphIDBridging.swift */,
72057214
775DFA9C215E758400620CED /* GraphMutation.swift */,
72067215
D67F29351F68333800E399A6 /* GraphSchema.swift */,
@@ -9614,6 +9623,7 @@
96149623
D0158A191EEB30A2006E7684 /* MessageThreadTemplates.swift in Sources */,
96159624
D01588AF1EEB2ED7006E7684 /* Project.VideoLenses.swift in Sources */,
96169625
D0158A091EEB30A2006E7684 /* ActivityTemplates.swift in Sources */,
9626+
6061009A2E71F6EB008CA69B /* AsyncApolloClient.swift in Sources */,
96179627
8A1557152693B28300017845 /* User+UserFragment.swift in Sources */,
96189628
8AC3E0AC2698EFAA00168BF8 /* Project+FetchAddOnsQueryData.swift in Sources */,
96199629
D01589811EEB2ED7006E7684 /* Update.swift in Sources */,
@@ -9906,6 +9916,7 @@
99069916
20A0938A2666578D00DEC258 /* CommentTests.swift in Sources */,
99079917
E10BE8E72B151D2700F73DC9 /* BlockUserInputTests.swift in Sources */,
99089918
77272D382370AB8E003B7A9C /* BackingPaymentSourceTests.swift in Sources */,
9919+
60F7FAB32E7323CB004B3177 /* MockApolloClient.swift in Sources */,
99099920
8A15574326951A4B00017845 /* Backing+BackingFragmentTests.swift in Sources */,
99109921
0665C74B26E930BC00A0EDA1 /* FetchProjectQueryTemplate.swift in Sources */,
99119922
06D23BBF26F2484500F76122 /* Project+FetchProjectFriendsQueryDataTests.swift in Sources */,
@@ -9962,6 +9973,7 @@
99629973
E1A149272ACE063400F49709 /* FetchBackerProjectsQueryDataTemplate.swift in Sources */,
99639974
194154D328D928C9004648C8 /* CreatePaymentSourceSetupIntentInputTests.swift in Sources */,
99649975
8AF34C762342D1D2000B211D /* UpdateBackingInputTests.swift in Sources */,
9976+
606100B72E7211B0008CA69B /* AsyncApolloClientTests.swift in Sources */,
99659977
33AA26DF2E3B146C002DBD60 /* UserMocks.swift in Sources */,
99669978
D0D10C191EEB4550005EBAD0 /* RewardTests.swift in Sources */,
99679979
D0D10C101EEB4550005EBAD0 /* MessageTests.swift in Sources */,

KsApi/AsyncApolloClient.swift

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
@preconcurrency import Apollo
2+
import Foundation
3+
import GraphAPI
4+
5+
/**
6+
An async/await wrapper around `ApolloClientProtocol`.
7+
8+
Purpose:
9+
- bridge Apollo’s callback APIs with Swift Concurrency.
10+
11+
Callback queue:
12+
- all Apollo completions run on `callbackQueue`(defaults to `.global(qos: .userInitiated)`.
13+
14+
Methods:
15+
- `fetch(_:)`: forwards `cachePolicy`/`contextIdentifier`
16+
- returns `GraphQLResult`or throws an Apollo error.
17+
- `perform(_:)`: forwards `publishResultToStore`
18+
- returns `GraphQLResult`or throws an Apollo error.
19+
20+
Concurrency: marked `@unchecked Sendable`. queue hopping is controlled by `callbackQueue`.
21+
22+
*/
23+
public final class AsyncApolloClient: @unchecked Sendable {
24+
private let client: ApolloClientProtocol
25+
private let callbackQueue: DispatchQueue
26+
27+
/// `callbackQueue` defaults to a background queue..
28+
public init(
29+
client: ApolloClientProtocol,
30+
/// using `.userInitiated` for scheduling the work the user is waiting on more efficiently.. `.background` can sometimes get deprioritized.
31+
callbackQueue: DispatchQueue = .global(qos: .userInitiated)
32+
) {
33+
self.client = client
34+
self.callbackQueue = callbackQueue
35+
}
36+
37+
// MARK: - Queries
38+
39+
public func fetch<Query: GraphQLQuery>(
40+
_ query: Query,
41+
cachePolicy: CachePolicy = .fetchIgnoringCacheCompletely,
42+
contextIdentifier: UUID? = nil
43+
) async throws -> GraphQLResult<Query.Data> {
44+
try await withCheckedThrowingContinuation { cont in
45+
_ = self.client.fetch(
46+
query: query,
47+
cachePolicy: cachePolicy,
48+
contextIdentifier: contextIdentifier,
49+
context: nil,
50+
queue: self.callbackQueue
51+
) { result in
52+
switch result {
53+
case let .success(value): cont.resume(returning: value)
54+
case let .failure(error): cont.resume(throwing: error)
55+
}
56+
}
57+
}
58+
}
59+
60+
// MARK: - Mutations
61+
62+
public func perform<Mutation: GraphQLMutation>(
63+
_ mutation: Mutation,
64+
publishResultToStore: Bool = true
65+
) async throws -> GraphQLResult<Mutation.Data> {
66+
try await withCheckedThrowingContinuation { cont in
67+
_ = self.client.perform(
68+
mutation: mutation,
69+
publishResultToStore: publishResultToStore,
70+
context: nil,
71+
queue: self.callbackQueue
72+
) { result in
73+
switch result {
74+
case let .success(value): cont.resume(returning: value)
75+
case let .failure(error): cont.resume(throwing: error)
76+
}
77+
}
78+
}
79+
}
80+
}

KsApi/AsyncApolloClientTests.swift

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
@preconcurrency import Apollo
2+
import GraphAPI
3+
@testable import KsApi
4+
import XCTest
5+
6+
final class AsyncApolloClientTests: XCTestCase {
7+
func testFetch_Errors_And_UpdatesCallbackQueue() async {
8+
let mock = MockApolloClient()
9+
mock.fetchShouldSucceed = false
10+
11+
let queue = DispatchQueue(label: "fetchQueue")
12+
let asyncClient = AsyncApolloClient(client: mock, callbackQueue: queue)
13+
14+
do {
15+
_ = try await asyncClient.fetch(StubQuery())
16+
17+
XCTFail("Expected to throw an error.")
18+
} catch {
19+
XCTAssertEqual(
20+
String(describing: error),
21+
String(describing: MockApolloClient.StubError.boom)
22+
)
23+
}
24+
25+
XCTAssertIdentical(mock.lastFetchQueue, queue)
26+
}
27+
28+
func testPerform_Errors_And_UpdatesCallbackQueue() async {
29+
let mock = MockApolloClient()
30+
mock.performShouldSucceed = false
31+
32+
let queue = DispatchQueue(label: "performQueue")
33+
let asyncClient = AsyncApolloClient(client: mock, callbackQueue: queue)
34+
35+
do {
36+
_ = try await asyncClient.perform(StubMutation())
37+
38+
XCTFail("Expected to throw an error.")
39+
} catch {
40+
XCTAssertEqual(
41+
String(describing: error),
42+
String(describing: MockApolloClient.StubError.boom)
43+
)
44+
}
45+
46+
XCTAssertIdentical(mock.lastPerformQueue, queue)
47+
}
48+
49+
func testFetch_Succeeds_And_UpdatesCallbackQueue() async throws {
50+
let mock = MockApolloClient()
51+
mock.fetchShouldSucceed = true
52+
53+
let queue = DispatchQueue(label: "fetchQueue.success")
54+
let asyncClient = AsyncApolloClient(client: mock, callbackQueue: queue)
55+
56+
let result: GraphQLResult<StubQuery.Data> = try await asyncClient.fetch(StubQuery())
57+
58+
XCTAssertNotNil(result.data)
59+
XCTAssertIdentical(mock.lastFetchQueue, queue)
60+
}
61+
62+
func testPerform_Succeeds_And_UpdatesCallbackQueue() async throws {
63+
let mock = MockApolloClient()
64+
mock.performShouldSucceed = true
65+
66+
let queue = DispatchQueue(label: "performQueue.success")
67+
let asyncClient = AsyncApolloClient(client: mock, callbackQueue: queue)
68+
69+
let result: GraphQLResult<StubMutation.Data> = try await asyncClient.perform(StubMutation())
70+
71+
XCTAssertNotNil(result.data)
72+
XCTAssertIdentical(mock.lastPerformQueue, queue)
73+
}
74+
}
75+
76+
// MARK: - Stub Query & Mutation
77+
78+
private final class StubQuery: GraphQLQuery {
79+
static var operationName: String { "StubQuery" }
80+
static var operationDocument: ApolloAPI.OperationDocument {
81+
.init(definition: .init("query StubQuery { __typename }"))
82+
}
83+
84+
init() {}
85+
86+
final class Data: GraphAPI.SelectionSet {
87+
static var __parentType: ApolloAPI.ParentType { GraphAPI.Objects.Query }
88+
static var __selections: [GraphAPI.Selection] { [] }
89+
var __data: GraphAPI.DataDict
90+
required init(_dataDict: GraphAPI.DataDict) { self.__data = _dataDict }
91+
}
92+
}
93+
94+
private final class StubMutation: GraphQLMutation {
95+
static var operationName: String { "StubMutation" }
96+
static var operationDocument: ApolloAPI.OperationDocument {
97+
.init(definition: .init("mutation StubMutation { __typename }"))
98+
}
99+
100+
init() {}
101+
102+
final class Data: GraphAPI.SelectionSet {
103+
static var __parentType: ApolloAPI.ParentType { GraphAPI.Objects.Mutation }
104+
static var __selections: [GraphAPI.Selection] { [] }
105+
var __data: GraphAPI.DataDict
106+
required init(_dataDict: GraphAPI.DataDict) { self.__data = _dataDict }
107+
}
108+
}

KsApi/MockApolloClient.swift

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import Apollo
2+
import GraphAPI
3+
import XCTest
4+
5+
/**
6+
A simple Apollo Mock used in our `AsyncApolloClientTests`.
7+
8+
- Simulates Apollo for unit tests for `AsyncApolloClient`..
9+
- Ultimately calls `resultHandler` on the provided `DispatchQueue`.
10+
11+
- Controlled by flags:
12+
- `fetchShouldSucceed` for `fetch(...)`
13+
- `performShouldSucceed` for `perform(...)`
14+
15+
- On success: returns a minimal `GraphQLResult` with only `__typename`.
16+
- On failure: returns `StubError.boom`.
17+
18+
- Records last queue/flags so tests can assert queue changes.
19+
*/
20+
public final class MockApolloClient: ApolloClientProtocol {
21+
enum StubError: Error, Equatable { case boom }
22+
23+
/// `ApolloStore` requires a cache and `InMemoryNormalizedCache()` is the simplest, zero-IO choice for testing.
24+
public let store: ApolloStore = ApolloStore(cache: InMemoryNormalizedCache())
25+
26+
/// Controls success/failure per call
27+
public var fetchShouldSucceed = false
28+
public var performShouldSucceed = false
29+
30+
private(set) var lastFetchQueue: DispatchQueue?
31+
private(set) var lastPerformQueue: DispatchQueue?
32+
33+
@discardableResult
34+
public func fetch<Query: GraphQLQuery>(
35+
query _: Query,
36+
cachePolicy _: Apollo.CachePolicy,
37+
contextIdentifier _: UUID?,
38+
context _: (any Apollo.RequestContext)?,
39+
queue: DispatchQueue,
40+
resultHandler: Apollo.GraphQLResultHandler<Query.Data>?
41+
) -> Cancellable {
42+
self.lastFetchQueue = queue
43+
44+
/// Mimic Apollo getting called on the provided queue
45+
queue.async {
46+
if self.fetchShouldSucceed {
47+
/// Build a minimal data payload for a Query
48+
let dataDict = GraphAPI.DataDict(data: ["__typename": "Query"], fulfilledFragments: [])
49+
let data = Query.Data(_dataDict: dataDict)
50+
51+
let result = GraphQLResult<Query.Data>(
52+
data: data,
53+
extensions: nil,
54+
errors: nil,
55+
source: .server,
56+
dependentKeys: nil
57+
)
58+
59+
resultHandler?(.success(result))
60+
} else {
61+
resultHandler?(.failure(StubError.boom))
62+
}
63+
}
64+
65+
return EmptyCancellable() /// nothing to cancel in this mock.
66+
}
67+
68+
@discardableResult
69+
public func perform<Mutation: GraphQLMutation>(
70+
mutation _: Mutation,
71+
publishResultToStore _: Bool,
72+
contextIdentifier _: UUID?,
73+
context _: (any Apollo.RequestContext)?,
74+
queue: DispatchQueue,
75+
resultHandler: Apollo.GraphQLResultHandler<Mutation.Data>?
76+
) -> Cancellable {
77+
self.lastPerformQueue = queue
78+
79+
/// Mimic Apollo getting called on the provided queue
80+
queue.async {
81+
if self.performShouldSucceed {
82+
/// Build a minimal data payload for a Mutation
83+
let dataDict = GraphAPI.DataDict(data: ["__typename": "Mutation"], fulfilledFragments: [])
84+
let data = Mutation.Data(_dataDict: dataDict)
85+
86+
let result = GraphQLResult<Mutation.Data>(
87+
data: data,
88+
extensions: nil,
89+
errors: nil,
90+
source: .server,
91+
dependentKeys: nil
92+
)
93+
94+
resultHandler?(.success(result))
95+
} else {
96+
resultHandler?(.failure(StubError.boom))
97+
}
98+
}
99+
return EmptyCancellable() /// nothing to cancel in this mock.
100+
}
101+
102+
private class EmptyCancellable: Cancellable { func cancel() {} }
103+
104+
// MARK: - Intentionally unimplemented methods to satisfy protocol conformance
105+
106+
public func clearCache(callbackQueue _: DispatchQueue, completion _: ((Result<Void, Error>) -> Void)?) {}
107+
108+
public func watch<Query>(
109+
query _: Query,
110+
cachePolicy _: Apollo.CachePolicy,
111+
context _: (any Apollo.RequestContext)?,
112+
callbackQueue _: DispatchQueue,
113+
resultHandler _: @escaping Apollo.GraphQLResultHandler<Query.Data>
114+
) -> Apollo.GraphQLQueryWatcher<Query> where Query: ApolloAPI.GraphQLQuery {
115+
fatalError("watch(query): not implemented in MockApolloClient")
116+
}
117+
118+
public func upload<Operation>(
119+
operation _: Operation,
120+
files _: [Apollo.GraphQLFile],
121+
context _: (any Apollo.RequestContext)?,
122+
queue _: DispatchQueue,
123+
resultHandler _: Apollo.GraphQLResultHandler<Operation.Data>?
124+
) -> any Apollo.Cancellable
125+
where Operation: ApolloAPI
126+
.GraphQLOperation {
127+
fatalError("upload(operation): not implemented in MockApolloClient")
128+
}
129+
130+
public func subscribe<Subscription>(
131+
subscription _: Subscription,
132+
context _: (any Apollo.RequestContext)?,
133+
queue _: DispatchQueue,
134+
resultHandler _: @escaping Apollo.GraphQLResultHandler<Subscription.Data>
135+
) -> any Apollo.Cancellable
136+
where Subscription: ApolloAPI
137+
.GraphQLSubscription {
138+
fatalError("subscribe(subscription): not implemented in MockApolloClient")
139+
}
140+
}

0 commit comments

Comments
 (0)