Skip to content

Commit 898281b

Browse files
authored
fix(API): Reachability resolve to GraphQL API (#1167)
* fix(API): Reachability resolve to GraphQL API * fix(API): fix unit tests * fix(API): reachabilityMapLock to allow concurrent operations * fix(API): PR comments move NSLock() into init()
1 parent 24377d8 commit 898281b

File tree

7 files changed

+359
-68
lines changed

7 files changed

+359
-68
lines changed

AmplifyPlugins/API/APICategoryPlugin.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@
113113
21D7A118237B54D90057D00D /* APIKeyURLRequestInterceptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21D7A0D5237B54D90057D00D /* APIKeyURLRequestInterceptor.swift */; };
114114
21D7A119237B54D90057D00D /* IAMURLRequestInterceptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21D7A0D6237B54D90057D00D /* IAMURLRequestInterceptor.swift */; };
115115
21D7A11A237B54D90057D00D /* AWSAPICategoryPluginError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21D7A0D7237B54D90057D00D /* AWSAPICategoryPluginError.swift */; };
116+
21DFD3EB2627514C0078833B /* AWSAPICategoryPlugin+ReachabilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21DFD3EA2627514C0078833B /* AWSAPICategoryPlugin+ReachabilityTests.swift */; };
116117
21F40A2B23A0423C0074678E /* GraphQLSyncBasedTests-amplifyconfiguration.json in Resources */ = {isa = PBXBuildFile; fileRef = 21F40A2923A0423C0074678E /* GraphQLSyncBasedTests-amplifyconfiguration.json */; };
117118
21F40A2E23A0707E0074678E /* GraphQLModelBasedTests-amplifyconfiguration.json in Resources */ = {isa = PBXBuildFile; fileRef = 21F40A2D23A0707E0074678E /* GraphQLModelBasedTests-amplifyconfiguration.json */; };
118119
21FDBB762587D9E40086FCDC /* GraphQLConnectionScenario6Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21FDBB752587D9E40086FCDC /* GraphQLConnectionScenario6Tests.swift */; };
@@ -427,6 +428,7 @@
427428
21D7A0D6237B54D90057D00D /* IAMURLRequestInterceptor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IAMURLRequestInterceptor.swift; sourceTree = "<group>"; };
428429
21D7A0D7237B54D90057D00D /* AWSAPICategoryPluginError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AWSAPICategoryPluginError.swift; sourceTree = "<group>"; };
429430
21D7A0DE237B54D90057D00D /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
431+
21DFD3EA2627514C0078833B /* AWSAPICategoryPlugin+ReachabilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AWSAPICategoryPlugin+ReachabilityTests.swift"; sourceTree = "<group>"; };
430432
21F40A2923A0423C0074678E /* GraphQLSyncBasedTests-amplifyconfiguration.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "GraphQLSyncBasedTests-amplifyconfiguration.json"; sourceTree = "<group>"; };
431433
21F40A2D23A0707E0074678E /* GraphQLModelBasedTests-amplifyconfiguration.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "GraphQLModelBasedTests-amplifyconfiguration.json"; sourceTree = "<group>"; };
432434
21FDBB752587D9E40086FCDC /* GraphQLConnectionScenario6Tests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GraphQLConnectionScenario6Tests.swift; sourceTree = "<group>"; };
@@ -1114,6 +1116,7 @@
11141116
B4DFA5DE237A611D0013E17B /* AWSAPICategoryPlugin+ConfigureTests.swift */,
11151117
B4DFA5CC237A611D0013E17B /* AWSAPICategoryPlugin+GraphQLBehaviorTests.swift */,
11161118
B4DFA5C9237A611D0013E17B /* AWSAPICategoryPlugin+InterceptorBehaviorTests.swift */,
1119+
21DFD3EA2627514C0078833B /* AWSAPICategoryPlugin+ReachabilityTests.swift */,
11171120
B4DFA5CD237A611D0013E17B /* AWSAPICategoryPlugin+ResetTests.swift */,
11181121
B4DFA5CA237A611D0013E17B /* AWSAPICategoryPlugin+RESTClientBehaviorTests.swift */,
11191122
B4DFA5D5237A611D0013E17B /* AWSAPICategoryPlugin+URLSessionBehaviorDelegateTests.swift */,
@@ -2448,6 +2451,7 @@
24482451
FAF2199A24C0F25E00171A3D /* OperationTestBase.swift in Sources */,
24492452
B4DFA5F3237A611D0013E17B /* GraphQLOperationRequestValidateTests.swift in Sources */,
24502453
216449982587F9BC00C548A5 /* GraphQLResponseDecoder+DecodeDataTests.swift in Sources */,
2454+
21DFD3EB2627514C0078833B /* AWSAPICategoryPlugin+ReachabilityTests.swift in Sources */,
24512455
FA45D78D24C1032E006CBEE9 /* RESTCombineTests.swift in Sources */,
24522456
21A4F42425A62E3600E1047D /* AppSyncListPayloadTests.swift in Sources */,
24532457
);

AmplifyPlugins/API/AWSAPICategoryPlugin/AWSAPIPlugin+Reachability.swift

Lines changed: 14 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,20 @@ extension AWSAPIPlugin {
1717

1818
@available(iOS 13.0, *)
1919
public func reachabilityPublisher(for apiName: String?) throws -> AnyPublisher<ReachabilityUpdate, Never>? {
20-
let hostName = try determineHostName(apiName: apiName)
20+
let endpoint = try pluginConfig.endpoints.getConfig(for: apiName)
21+
guard let hostName = endpoint.baseURL.host else {
22+
let error = APIError.invalidConfiguration("Invalid endpoint configuration",
23+
"""
24+
baseURL does not contain a valid hostname
25+
"""
26+
)
27+
throw error
28+
}
29+
30+
reachabilityMapLock.lock()
31+
defer {
32+
reachabilityMapLock.unlock()
33+
}
2134
if let networkReachability = reachabilityMap[hostName] {
2235
return networkReachability.publisher
2336
}
@@ -32,51 +45,4 @@ extension AWSAPIPlugin {
3245
return nil
3346
}
3447
}
35-
36-
private func determineHostName(apiName: String?) throws -> String {
37-
if let apiName = apiName {
38-
guard let baseUrl = pluginConfig.endpoints[apiName]?.baseURL,
39-
let host = baseUrl.host else {
40-
let error = APIError.invalidConfiguration("Invalid endpoint configuration for \(apiName)",
41-
"""
42-
baseURL does not contain a valid hostname
43-
for apiName: \(apiName)
44-
"""
45-
)
46-
throw error
47-
}
48-
return host
49-
}
50-
51-
if pluginConfig.endpoints.count > 1 {
52-
let error = APIError.invalidConfiguration("Unable to determine which endpoint configuration",
53-
"""
54-
Pass in the apiName to disambiguate between which endpoint
55-
you are requesting reachability for
56-
"""
57-
)
58-
throw error
59-
}
60-
61-
guard let configEntry = pluginConfig.endpoints.first else {
62-
let error = APIError.invalidConfiguration("No API configurations found",
63-
"""
64-
Review how the API category is being instantiated and
65-
configured.
66-
"""
67-
)
68-
throw error
69-
}
70-
71-
guard let host = configEntry.value.baseURL.host else {
72-
let error = APIError.invalidConfiguration("Invalid endpoint configuration",
73-
"""
74-
baseURL does not contain a valid hostname
75-
"""
76-
)
77-
throw error
78-
}
79-
return host
80-
}
81-
8248
}

AmplifyPlugins/API/AWSAPICategoryPlugin/AWSAPIPlugin+Resettable.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ extension AWSAPIPlugin: Resettable {
2626
authService = nil
2727

2828
if #available(iOS 13.0, *) {
29+
reachabilityMapLock.lock()
2930
reachabilityMap.removeAll()
31+
reachabilityMapLock.unlock()
3032
}
3133

3234
subscriptionConnectionFactory = nil

AmplifyPlugins/API/AWSAPICategoryPlugin/AWSAPIPlugin.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,14 +57,18 @@ final public class AWSAPIPlugin: NSObject, APICategoryPlugin {
5757
}
5858
}
5959

60+
/// Lock used for performing operations atomically when getting and setting `reachabilityMap`.
61+
let reachabilityMapLock: NSLock
62+
6063
public init(
6164
modelRegistration: AmplifyModelRegistration? = nil,
6265
sessionFactory: URLSessionBehaviorFactory? = nil,
6366
apiAuthProviderFactory: APIAuthProviderFactory? = nil
6467
) {
6568
self.mapper = OperationTaskMapper()
6669
self.queue = OperationQueue()
67-
self.authProviderFactory = apiAuthProviderFactory ?? APIAuthProviderFactory()
70+
self.authProviderFactory = apiAuthProviderFactory ?? APIAuthProviderFactory()
71+
self.reachabilityMapLock = NSLock()
6872
super.init()
6973

7074
modelRegistration?.registerModels(registry: ModelRegistry.self)

AmplifyPlugins/API/AWSAPICategoryPlugin/Configuration/AWSAPICategoryPluginConfiguration+EndpointConfig.swift

Lines changed: 56 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -281,29 +281,23 @@ public extension AWSAPICategoryPluginConfiguration {
281281
}
282282

283283
extension Dictionary where Key == String, Value == AWSAPICategoryPluginConfiguration.EndpointConfig {
284-
func getConfig(for apiName: String?,
285-
endpointType: AWSAPICategoryPluginEndpointType) throws ->
284+
285+
/// Getting the `EndpointConfig` resolves to the following rules:
286+
/// 1. If `apiName` is specified, retrieve the endpoint configuration for this api
287+
/// 2. If `apiName` is not specified, and `endpointType` is, retrieve the endpoint if there is only one.
288+
/// 3. If nothing is specified, return the endpoint only if there is a single one, with GraphQL taking precedent
289+
/// over REST.
290+
func getConfig(for apiName: String? = nil,
291+
endpointType: AWSAPICategoryPluginEndpointType? = nil) throws ->
286292
AWSAPICategoryPluginConfiguration.EndpointConfig {
287293
if let apiName = apiName {
288294
return try getConfig(for: apiName)
289295
}
290-
291-
let apiForEndpointType = filter { (_, endpointConfig) -> Bool in
292-
return endpointConfig.endpointType == endpointType
293-
}
294-
295-
guard let endpointConfig = apiForEndpointType.first else {
296-
throw APIError.invalidConfiguration("Missing API for \(endpointType) endpointType",
297-
"Add the \(endpointType) API to configuration.")
298-
}
299-
300-
if apiForEndpointType.count > 1 {
301-
throw APIError.invalidConfiguration(
302-
"More than one \(endpointType) API configured. Could not infer which API to call",
303-
"Use the apiName to specify which API to call")
296+
if let endpointType = endpointType {
297+
return try getConfig(for: endpointType)
304298
}
305299

306-
return endpointConfig.value
300+
return try getConfig()
307301
}
308302

309303
private func getConfig(for apiName: String) throws -> AWSAPICategoryPluginConfiguration.EndpointConfig {
@@ -322,4 +316,49 @@ extension Dictionary where Key == String, Value == AWSAPICategoryPluginConfigura
322316

323317
return endpointConfig
324318
}
319+
320+
/// Retrieve the endpoint configuration when there is only one endpoint of the specified `endpointType`
321+
private func getConfig(for endpointType: AWSAPICategoryPluginEndpointType) throws ->
322+
AWSAPICategoryPluginConfiguration.EndpointConfig {
323+
let apiForEndpointType = filter { (_, endpointConfig) -> Bool in
324+
return endpointConfig.endpointType == endpointType
325+
}
326+
327+
guard let endpointConfig = apiForEndpointType.first else {
328+
throw APIError.invalidConfiguration("Missing API for \(endpointType) endpointType",
329+
"Add the \(endpointType) API to configuration.")
330+
}
331+
332+
if apiForEndpointType.count > 1 {
333+
throw APIError.invalidConfiguration(
334+
"More than one \(endpointType) API configured. Could not infer which API to call",
335+
"Use the apiName to specify which API to call")
336+
}
337+
return endpointConfig.value
338+
}
339+
340+
/// Retrieve the endpoint only if there is a single one, with GraphQL taking precedent over REST.
341+
private func getConfig() throws -> AWSAPICategoryPluginConfiguration.EndpointConfig {
342+
let graphQLEndpoints = filter { (_, endpointConfig) -> Bool in
343+
return endpointConfig.endpointType == .graphQL
344+
}
345+
346+
if graphQLEndpoints.count == 1, let endpoint = graphQLEndpoints.first {
347+
return endpoint.value
348+
}
349+
350+
let restEndpoints = filter { (_, endpointConfig) -> Bool in
351+
return endpointConfig.endpointType == .rest
352+
}
353+
354+
if restEndpoints.count == 1, let endpoint = restEndpoints.first {
355+
return endpoint.value
356+
}
357+
358+
throw APIError.invalidConfiguration("Unable to resolve endpoint configuration",
359+
"""
360+
Pass in the apiName to specify the endpoint you are
361+
retrieving the config for
362+
""")
363+
}
325364
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
//
2+
// Copyright Amazon.com Inc. or its affiliates.
3+
// All Rights Reserved.
4+
//
5+
// SPDX-License-Identifier: Apache-2.0
6+
//
7+
8+
import XCTest
9+
import Foundation
10+
import AWSPluginsCore
11+
12+
@testable import Amplify
13+
@testable import AmplifyTestCommon
14+
@testable import AWSAPICategoryPlugin
15+
16+
@available(iOS 13.0, *)
17+
class AWSAPICategoryPluginReachabilityTests: XCTestCase {
18+
19+
var apiPlugin: AWSAPIPlugin!
20+
21+
override func setUp() {
22+
apiPlugin = AWSAPIPlugin()
23+
}
24+
25+
override func tearDown() {
26+
if let api = apiPlugin {
27+
api.reset {
28+
}
29+
}
30+
}
31+
32+
func testReachabilityReturnsGraphQLAPI() throws {
33+
let graphQLAPI = "graphQLAPI"
34+
do {
35+
let endpointConfig = [graphQLAPI: try getEndpointConfig(apiName: graphQLAPI, endpointType: .graphQL)]
36+
let pluginConfig = AWSAPICategoryPluginConfiguration(endpoints: endpointConfig)
37+
let dependencies = AWSAPIPlugin.ConfigurationDependencies(
38+
pluginConfig: pluginConfig,
39+
authService: MockAWSAuthService(),
40+
subscriptionConnectionFactory: AWSSubscriptionConnectionFactory()
41+
)
42+
apiPlugin.configure(using: dependencies)
43+
} catch {
44+
XCTFail("Failed to create endpoint config")
45+
}
46+
47+
let publisher = try apiPlugin.reachabilityPublisher()
48+
XCTAssertNotNil(publisher)
49+
XCTAssertEqual(apiPlugin.reachabilityMap.count, 1)
50+
guard let reachability = apiPlugin.reachabilityMap.first else {
51+
XCTFail("Missing expeected `reachability`")
52+
return
53+
}
54+
XCTAssertEqual(reachability.key, graphQLAPI)
55+
}
56+
57+
func testReachabilityReturnsGraphQLAPIForMultipleEndpoints() throws {
58+
let graphQLAPI = "graphQLAPI"
59+
let restAPI = "restAPI"
60+
do {
61+
let endpointConfig = [graphQLAPI: try getEndpointConfig(apiName: graphQLAPI, endpointType: .graphQL),
62+
restAPI: try getEndpointConfig(apiName: restAPI, endpointType: .rest)]
63+
let pluginConfig = AWSAPICategoryPluginConfiguration(endpoints: endpointConfig)
64+
let dependencies = AWSAPIPlugin.ConfigurationDependencies(
65+
pluginConfig: pluginConfig,
66+
authService: MockAWSAuthService(),
67+
subscriptionConnectionFactory: AWSSubscriptionConnectionFactory()
68+
)
69+
apiPlugin.configure(using: dependencies)
70+
} catch {
71+
XCTFail("Failed to create endpoint config")
72+
}
73+
74+
let publisher = try apiPlugin.reachabilityPublisher()
75+
XCTAssertNotNil(publisher)
76+
XCTAssertEqual(apiPlugin.reachabilityMap.count, 1)
77+
guard let reachability = apiPlugin.reachabilityMap.first else {
78+
XCTFail("Missing expeected `reachability`")
79+
return
80+
}
81+
XCTAssertEqual(reachability.key, graphQLAPI)
82+
}
83+
84+
func testReachabilityConcurrentPerform() throws {
85+
let graphQLAPI = "graphQLAPI"
86+
let restAPI = "restAPI"
87+
do {
88+
let endpointConfig = [graphQLAPI: try getEndpointConfig(apiName: graphQLAPI, endpointType: .graphQL),
89+
restAPI: try getEndpointConfig(apiName: restAPI, endpointType: .rest)]
90+
let pluginConfig = AWSAPICategoryPluginConfiguration(endpoints: endpointConfig)
91+
let dependencies = AWSAPIPlugin.ConfigurationDependencies(
92+
pluginConfig: pluginConfig,
93+
authService: MockAWSAuthService(),
94+
subscriptionConnectionFactory: AWSSubscriptionConnectionFactory()
95+
)
96+
apiPlugin.configure(using: dependencies)
97+
} catch {
98+
XCTFail("Failed to create endpoint config")
99+
}
100+
101+
let concurrentPerformCompleted = expectation(description: "concurrent perform completed")
102+
concurrentPerformCompleted.expectedFulfillmentCount = 1_000
103+
DispatchQueue.concurrentPerform(iterations: 1_000) { _ in
104+
do {
105+
let graphQLAPIPublisher = try apiPlugin.reachabilityPublisher(for: graphQLAPI)
106+
XCTAssertNotNil(graphQLAPIPublisher)
107+
let restAPIPublisher = try apiPlugin.reachabilityPublisher(for: restAPI)
108+
XCTAssertNotNil(restAPIPublisher)
109+
} catch {
110+
XCTFail("\(error)")
111+
}
112+
concurrentPerformCompleted.fulfill()
113+
114+
}
115+
wait(for: [concurrentPerformCompleted], timeout: 1)
116+
XCTAssertEqual(apiPlugin.reachabilityMap.count, 2)
117+
}
118+
119+
// MARK: - Helpers
120+
121+
func getEndpointConfig(apiName: String, endpointType: AWSAPICategoryPluginEndpointType) throws ->
122+
AWSAPICategoryPluginConfiguration.EndpointConfig {
123+
try AWSAPICategoryPluginConfiguration.EndpointConfig(
124+
name: apiName,
125+
baseURL: URL(string: "http://\(apiName)")!,
126+
region: nil,
127+
authorizationType: AWSAuthorizationType.none,
128+
authorizationConfiguration: AWSAuthorizationConfiguration.none,
129+
endpointType: endpointType,
130+
apiAuthProviderFactory: APIAuthProviderFactory())
131+
}
132+
}

0 commit comments

Comments
 (0)