diff --git a/Braintree.xcodeproj/project.pbxproj b/Braintree.xcodeproj/project.pbxproj index 7ea3a7cca2..031c3352e9 100644 --- a/Braintree.xcodeproj/project.pbxproj +++ b/Braintree.xcodeproj/project.pbxproj @@ -117,6 +117,7 @@ 80581A8C25531D0A00006F53 /* BTConfiguration+ThreeDSecure_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80581A8B25531D0A00006F53 /* BTConfiguration+ThreeDSecure_Tests.swift */; }; 80581B1D2553319C00006F53 /* BTGraphQLHTTP_SSLPinning_IntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80581B1C2553319C00006F53 /* BTGraphQLHTTP_SSLPinning_IntegrationTests.swift */; }; 806C85632B90EBED00A2754C /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 806C85622B90EBED00A2754C /* PrivacyInfo.xcprivacy */; }; + 807296042C41B63F0093F2AB /* ConfigurationCallbackStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 807296032C41B63F0093F2AB /* ConfigurationCallbackStorage.swift */; }; 8075CBEE2B1B735200CA6265 /* BTAPIRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8075CBED2B1B735200CA6265 /* BTAPIRequest.swift */; }; 80842DA72B8E49EF00A5CD92 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 80842DA62B8E49EF00A5CD92 /* PrivacyInfo.xcprivacy */; }; 8087C10F2BFBACCA0020FC2E /* TokenizationKey_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8087C10E2BFBACCA0020FC2E /* TokenizationKey_Tests.swift */; }; @@ -855,6 +856,7 @@ 8064F3942B1E4FEB0059C4CB /* BTShopperInsightsClient_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTShopperInsightsClient_Tests.swift; sourceTree = ""; }; 8064F3962B1E63800059C4CB /* BTShopperInsightsRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTShopperInsightsRequest.swift; sourceTree = ""; }; 806C85622B90EBED00A2754C /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; + 807296032C41B63F0093F2AB /* ConfigurationCallbackStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationCallbackStorage.swift; sourceTree = ""; }; 8075CBED2B1B735200CA6265 /* BTAPIRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTAPIRequest.swift; sourceTree = ""; }; 80842DA62B8E49EF00A5CD92 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 8087C10E2BFBACCA0020FC2E /* TokenizationKey_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenizationKey_Tests.swift; sourceTree = ""; }; @@ -1433,6 +1435,7 @@ BEBD05212A1FE8BE0003C15C /* BTWebAuthenticationSession.swift */, BEB9BF522A26872B00A3673E /* BTWebAuthenticationSessionClient.swift */, BE698EA128AA8EEA001D9B10 /* ConfigurationCache.swift */, + 807296032C41B63F0093F2AB /* ConfigurationCallbackStorage.swift */, 45EFC3962C2DBF32005E7F5B /* ConfigurationLoader.swift */, 80D280CC2BE9354B00762D27 /* Date+MillisecondTimestamp.swift */, 80F86FEF29FC2ED6003297FF /* Encodable+Dictionary.swift */, @@ -3258,6 +3261,7 @@ 800E78C429E0DD5300D1B0FC /* FPTIBatchData.swift in Sources */, BED00CB028A579D700D74AEC /* BTClientToken.swift in Sources */, 5708E0A628809AD9007946B9 /* BTJSON.swift in Sources */, + 807296042C41B63F0093F2AB /* ConfigurationCallbackStorage.swift in Sources */, 574891ED286F7ECA0020DA36 /* BTClientMetadata.swift in Sources */, BE24C66E28E49A730067B11A /* BTAPIClientError.swift in Sources */, BC17F9BC28D24C9E004B18CC /* BTGraphQLErrorTree.swift in Sources */, diff --git a/CHANGELOG.md b/CHANGELOG.md index ff75a3a217..7d2063a3ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Braintree iOS SDK Release Notes +## unreleased +* Prevent duplicate outbound `v1/configuration` requests + ## 6.23.0 (2024-07-15) * BraintreeShopperInsights (BETA) * Add error when using an invalid authorization type diff --git a/Sources/BraintreeCore/ConfigurationCallbackStorage.swift b/Sources/BraintreeCore/ConfigurationCallbackStorage.swift new file mode 100644 index 0000000000..5f4d540d7c --- /dev/null +++ b/Sources/BraintreeCore/ConfigurationCallbackStorage.swift @@ -0,0 +1,26 @@ +import Foundation + +/// Used to store, access, and manage an array of to-be-invoked `BTConfiguration` GET result callbacks in a thread-safe manner +class ConfigurationCallbackStorage { + + private let queue = DispatchQueue(label: "com.braintreepayments.ConfigurationCallbackStorage") + private var pendingCompletions: [(BTConfiguration?, Error?) -> Void] = [] + + /// The number of completions that are waiting to be invoked + var count: Int { + queue.sync { pendingCompletions.count } + } + + /// Adds a pending, yet to-be-invoked completion handler + func add(_ completion: @escaping (BTConfiguration?, Error?) -> Void) { + queue.sync { pendingCompletions.append(completion) } + } + + /// Executes and clears all pending completion handlers + func invoke(_ configuration: BTConfiguration?, _ error: Error?) { + queue.sync { + pendingCompletions.forEach { $0(configuration, error) } + pendingCompletions.removeAll() + } + } +} diff --git a/Sources/BraintreeCore/ConfigurationLoader.swift b/Sources/BraintreeCore/ConfigurationLoader.swift index c787dc37e1..ecc15220f6 100644 --- a/Sources/BraintreeCore/ConfigurationLoader.swift +++ b/Sources/BraintreeCore/ConfigurationLoader.swift @@ -7,6 +7,7 @@ class ConfigurationLoader { private let configPath = "v1/configuration" private let configurationCache = ConfigurationCache.shared private let http: BTHTTP + private let pendingCompletions = ConfigurationCallbackStorage() // MARK: - Intitializer @@ -39,26 +40,32 @@ class ConfigurationLoader { completion(cachedConfig, nil) return } - - http.get(configPath, parameters: BTConfigurationRequest()) { [weak self] body, response, error in - guard let self else { - completion(nil, BTAPIClientError.deallocated) - return - } - - if let error { - completion(nil, error) - return - } else if response?.statusCode != 200 || body == nil { - completion(nil, BTAPIClientError.configurationUnavailable) - return - } else { - let configuration = BTConfiguration(json: body) - - try? configurationCache.putInCache(authorization: http.authorization.bearer, configuration: configuration) + + pendingCompletions.add(completion) + + // If this is the 1st `v1/config` GET attempt, proceed with firing the network request. + // Otherwise, there is already a pending network request. + if pendingCompletions.count == 1 { + http.get(configPath, parameters: BTConfigurationRequest()) { [weak self] body, response, error in + guard let self else { + self?.notifyCompletions(nil, BTAPIClientError.deallocated) + return + } - completion(configuration, nil) - return + if let error { + notifyCompletions(nil, error) + return + } else if response?.statusCode != 200 || body == nil { + notifyCompletions(nil, BTAPIClientError.configurationUnavailable) + return + } else { + let configuration = BTConfiguration(json: body) + + try? configurationCache.putInCache(authorization: http.authorization.bearer, configuration: configuration) + + notifyCompletions(configuration, nil) + return + } } } } @@ -74,4 +81,10 @@ class ConfigurationLoader { } } } + + // MARK: - Private Methods + + func notifyCompletions(_ configuration: BTConfiguration?, _ error: Error?) { + pendingCompletions.invoke(configuration, error) + } } diff --git a/UnitTests/BraintreeCoreTests/ConfigurationLoader_Tests.swift b/UnitTests/BraintreeCoreTests/ConfigurationLoader_Tests.swift index 7163cfa2d2..a0198fc5bf 100644 --- a/UnitTests/BraintreeCoreTests/ConfigurationLoader_Tests.swift +++ b/UnitTests/BraintreeCoreTests/ConfigurationLoader_Tests.swift @@ -131,4 +131,13 @@ class ConfigurationLoader_Tests: XCTestCase { XCTAssertEqual(error as NSError, mockError) } } + + func testGetConfig_whenCalledInQuickSequence_onlySendsOneNetworkRequest() { + sut.getConfig() { _, _ in } + sut.getConfig() { _, _ in } + sut.getConfig() { _, _ in } + sut.getConfig() { _, _ in } + + XCTAssertEqual(mockHTTP.GETRequestCount, 1) + } }