diff --git a/Braintree.xcodeproj/project.pbxproj b/Braintree.xcodeproj/project.pbxproj index 29bbc30f31..dcd1855e71 100644 --- a/Braintree.xcodeproj/project.pbxproj +++ b/Braintree.xcodeproj/project.pbxproj @@ -105,6 +105,7 @@ 80BA64AC29D788E000E15264 /* BTLocalPaymentResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80BA64AB29D788E000E15264 /* BTLocalPaymentResult.swift */; }; 80BA64B229D7937E00E15264 /* BTLocalPaymentRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80BA64B129D7937E00E15264 /* BTLocalPaymentRequest.swift */; }; 80BA64B429D795D000E15264 /* BTLocalPaymentRequestDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80BA64B329D795D000E15264 /* BTLocalPaymentRequestDelegate.swift */; }; + 80C10F832BE090AA00BFA2EE /* ConfigurationCache_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE698EA528B3CDAD001D9B10 /* ConfigurationCache_Tests.swift */; }; 80CD34002A6042FC009545F5 /* CardinalSessionTestable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80CD33FF2A6042FC009545F5 /* CardinalSessionTestable.swift */; }; 80CD34012A604307009545F5 /* MockCardinalSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80CD33FD2A603892009545F5 /* MockCardinalSession.swift */; }; 80CF988529DB64D400D51979 /* BTLocalPaymentError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80CF988429DB64D400D51979 /* BTLocalPaymentError.swift */; }; @@ -225,9 +226,8 @@ BE642DA927D013A400694A5B /* BTSEPADirectDebitClient_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE642D8C27D0130300694A5B /* BTSEPADirectDebitClient_Tests.swift */; }; BE642DAB27D01BCA00694A5B /* BTSEPADirectDebitNonce_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE642DAA27D01BCA00694A5B /* BTSEPADirectDebitNonce_Tests.swift */; }; BE698EA028AA8DCB001D9B10 /* BTHTTP.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE698E9F28AA8DCB001D9B10 /* BTHTTP.swift */; }; - BE698EA228AA8EEA001D9B10 /* BTCacheDateValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE698EA128AA8EEA001D9B10 /* BTCacheDateValidator.swift */; }; + BE698EA228AA8EEA001D9B10 /* ConfigurationCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE698EA128AA8EEA001D9B10 /* ConfigurationCache.swift */; }; BE698EA428AD2C10001D9B10 /* BTCoreConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE698EA328AD2C10001D9B10 /* BTCoreConstants.swift */; }; - BE698EA628B3CDAD001D9B10 /* BTCacheDateValidator_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE698EA528B3CDAD001D9B10 /* BTCacheDateValidator_Tests.swift */; }; BE70A963284FA3F000F6D3F7 /* BTDataCollectorError.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE70A962284FA3F000F6D3F7 /* BTDataCollectorError.swift */; }; BE70A965284FA9DE00F6D3F7 /* MockBTDataCollector.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE70A964284FA9DE00F6D3F7 /* MockBTDataCollector.swift */; }; BE70A983284FC07C00F6D3F7 /* BraintreeDataCollector.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A76D7C001BB1CAB00000FA6A /* BraintreeDataCollector.framework */; }; @@ -860,9 +860,9 @@ BE642DA027D0132A00694A5B /* BraintreeSEPADirectDebitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BraintreeSEPADirectDebitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; BE642DAA27D01BCA00694A5B /* BTSEPADirectDebitNonce_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTSEPADirectDebitNonce_Tests.swift; sourceTree = ""; }; BE698E9F28AA8DCB001D9B10 /* BTHTTP.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTHTTP.swift; sourceTree = ""; }; - BE698EA128AA8EEA001D9B10 /* BTCacheDateValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTCacheDateValidator.swift; sourceTree = ""; }; + BE698EA128AA8EEA001D9B10 /* ConfigurationCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationCache.swift; sourceTree = ""; }; BE698EA328AD2C10001D9B10 /* BTCoreConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTCoreConstants.swift; sourceTree = ""; }; - BE698EA528B3CDAD001D9B10 /* BTCacheDateValidator_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTCacheDateValidator_Tests.swift; sourceTree = ""; }; + BE698EA528B3CDAD001D9B10 /* ConfigurationCache_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationCache_Tests.swift; sourceTree = ""; }; BE698EAA28B50F41001D9B10 /* BTClientToken_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTClientToken_Tests.swift; sourceTree = ""; }; BE70A962284FA3F000F6D3F7 /* BTDataCollectorError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTDataCollectorError.swift; sourceTree = ""; }; BE70A964284FA9DE00F6D3F7 /* MockBTDataCollector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBTDataCollector.swift; sourceTree = ""; }; @@ -1215,7 +1215,6 @@ 579DAEC6286E064500FCE87F /* BTAppContextSwitchClient.swift */, 579DAEC4286E04A700FCE87F /* BTAppContextSwitcher.swift */, BED00CAD28A5419900D74AEC /* BTBinData.swift */, - BE698EA128AA8EEA001D9B10 /* BTCacheDateValidator.swift */, BE32ACBB2907744400A61FED /* BTCardNetwork.swift */, 574891EC286F7ECA0020DA36 /* BTClientMetadata.swift */, 574891EA286F7E4F0020DA36 /* BTClientMetadataIntegration.swift */, @@ -1243,6 +1242,7 @@ BED7493528579BAC0074C818 /* BTURLUtils.swift */, BEBD05212A1FE8BE0003C15C /* BTWebAuthenticationSession.swift */, BEB9BF522A26872B00A3673E /* BTWebAuthenticationSessionClient.swift */, + BE698EA128AA8EEA001D9B10 /* ConfigurationCache.swift */, 80F86FEF29FC2ED6003297FF /* Encodable+Dictionary.swift */, 80A6C6182B21205900416D50 /* UIApplication+URLOpener.swift */, 8053F05829FB2F700076F988 /* URL+IsPayPal.swift */, @@ -1564,12 +1564,12 @@ A908436924FD88C3004134CA /* Helpers */ = { isa = PBXGroup; children = ( + BEBC6F3429380BA6004E25A0 /* BTExceptionCatcher.h */, + BEBC6F3129380B82004E25A0 /* BTExceptionCatcher.m */, BE54C0342912B6BC009C6CEE /* BTHTTPTestProtocol.swift */, - 80BA3C282B23892700900BBB /* FakeRequest.swift */, 428F976426727333001042E1 /* BTMockOpenURLContext.h */, 428F976526727333001042E1 /* BTMockOpenURLContext.m */, - BEBC6F3129380B82004E25A0 /* BTExceptionCatcher.m */, - BEBC6F3429380BA6004E25A0 /* BTExceptionCatcher.h */, + 80BA3C282B23892700900BBB /* FakeRequest.swift */, ); path = Helpers; sourceTree = ""; @@ -1719,7 +1719,6 @@ 842B68F01BCF083E0039634F /* BTAPIClient_Tests.swift */, A7CCE2AD1B67F26C006EA661 /* BTAppContextSwitcher_Tests.swift */, 030DBF451F3A5F5B00E959F0 /* BTBinData_Tests.swift */, - BE698EA528B3CDAD001D9B10 /* BTCacheDateValidator_Tests.swift */, BEBC6F252937A510004E25A0 /* BTClientMetadata_Tests.swift */, BE698EAA28B50F41001D9B10 /* BTClientToken_Tests.swift */, A7B7989B1C233C57001327FA /* BTConfiguration_Tests.swift */, @@ -1730,6 +1729,7 @@ 805FD35B2331780F0000B514 /* BTPostalAddress_Tests.swift */, A7E93E571B601EE900957223 /* BTURLUtils_Tests.swift */, A7B861BE1C24B19300A2422E /* BTVersion_Tests.swift */, + BE698EA528B3CDAD001D9B10 /* ConfigurationCache_Tests.swift */, 8053F05629FAD4790076F988 /* Encodable+Dictionary_Tests.swift */, A908436924FD88C3004134CA /* Helpers */, A9E5C22824FD6D0800EE691F /* Info.plist */, @@ -2810,7 +2810,7 @@ BE5C8C0328A2C183004F9130 /* BTConfiguration+Core.swift in Sources */, BEE2E4E6290080BD00C03FDD /* BTAnalyticsServiceError.swift in Sources */, BE9FB82B2898324C00D6FE2F /* BTPaymentMethodNonce.swift in Sources */, - BE698EA228AA8EEA001D9B10 /* BTCacheDateValidator.swift in Sources */, + BE698EA228AA8EEA001D9B10 /* ConfigurationCache.swift in Sources */, BEB9BF532A26872B00A3673E /* BTWebAuthenticationSessionClient.swift in Sources */, 80F86FF029FC2ED6003297FF /* Encodable+Dictionary.swift in Sources */, 579DAEC7286E064500FCE87F /* BTAppContextSwitchClient.swift in Sources */, @@ -3040,7 +3040,6 @@ A948D6D224FAB8BA00F4F178 /* BTAmericanExpressRewardsBalance_Tests.swift in Sources */, BEDB820429B10ADA00075AF3 /* BTApplePayAnalytics_Tests.swift in Sources */, 3B0EF89629B28F3800456973 /* BTAmericanExpressAnalytics_Tests.swift in Sources */, - BE698EA628B3CDAD001D9B10 /* BTCacheDateValidator_Tests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -3130,6 +3129,7 @@ A908436224FD82A9004134CA /* BTAppContextSwitcher_Tests.swift in Sources */, BEBC6F282937BD1F004E25A0 /* BTGraphQLHTTP_Tests.swift in Sources */, BE54C0352912B6BC009C6CEE /* BTHTTPTestProtocol.swift in Sources */, + 80C10F832BE090AA00BFA2EE /* ConfigurationCache_Tests.swift in Sources */, BEDA91A028EDDE64007441D9 /* FakeAnalyticsService.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/CHANGELOG.md b/CHANGELOG.md index 94940ba87f..27b3e8a5b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Braintree iOS SDK Release Notes +## unreleased +* Remove throttle delay in accessing configuration, added in v5.9.0 + * Move from URLCache to NSCache for configuration caching + ## 6.18.0 (2024-04-25) * BraintreePayPalNativeCheckout * Bump PayPalCheckout to version 1.3.0 with code signing & a privacy manifest file. diff --git a/Sources/BraintreeCore/BTAPIClient.swift b/Sources/BraintreeCore/BTAPIClient.swift index f1e20c50f5..e7fe6f571e 100644 --- a/Sources/BraintreeCore/BTAPIClient.swift +++ b/Sources/BraintreeCore/BTAPIClient.swift @@ -20,28 +20,11 @@ import Foundation public private(set) var metadata: BTClientMetadata // MARK: - Internal Properties - - /// Used to fetch and store configurations in the URL Cache of the session + var configurationHTTP: BTHTTP? - var http: BTHTTP? var graphQLHTTP: BTGraphQLHTTP? - var session: URLSession { - let configurationQueue: OperationQueue = OperationQueue() - configurationQueue.name = "com.braintreepayments.BTAPIClient" - - // BTHTTP's default NSURLSession does not cache responses, but we want the BTHTTP instance that fetches configuration to cache aggressively - let configuration: URLSessionConfiguration = URLSessionConfiguration.default - let configurationCache: URLCache = URLCache(memoryCapacity: 1 * 1024 * 1024, diskCapacity: 0, diskPath: nil) - - configuration.urlCache = configurationCache - - // Use the caching logic defined in the protocol implementation, if any, for a particular URL load request. - configuration.requestCachePolicy = .useProtocolCachePolicy - return URLSession(configuration: configuration) - } - /// Exposed for testing analytics /// By default, the `BTAnalyticsService` instance is static/shared so that only one queue of events exists. /// The "singleton" is managed here because the analytics service depends on `BTAPIClient`. @@ -94,8 +77,6 @@ import Foundation } } - configurationHTTP?.session = session - // Kickoff the background request to fetch the config fetchOrReturnRemoteConfiguration { configuration, error in // No-op @@ -112,8 +93,6 @@ import Foundation if graphQLHTTP != nil && graphQLHTTP?.session != nil { graphQLHTTP?.session.finishTasksAndInvalidate() } - - configurationHTTP?.session.configuration.urlCache?.removeAllCachedResponses() } // MARK: - Public Methods @@ -138,13 +117,23 @@ import Foundation // - If fetching fails, return error var configPath: String = "v1/configuration" - var configuration: BTConfiguration? if let clientToken { configPath = clientToken.configURL.absoluteString } + + guard let authorization = clientToken?.authorizationFingerprint ?? tokenizationKey else { + completion(nil, BTAPIClientError.configurationUnavailable) + return + } + + if let cachedConfig = try? ConfigurationCache.shared.getFromCache(authorization: authorization) { + setupHTTPCredentials(cachedConfig) + completion(cachedConfig, nil) + return + } - configurationHTTP?.get(configPath, parameters: BTConfigurationRequest(), shouldCache: true) { [weak self] body, response, error in + configurationHTTP?.get(configPath, parameters: BTConfigurationRequest()) { [weak self] body, response, error in guard let self else { completion(nil, BTAPIClientError.deallocated) return @@ -153,34 +142,18 @@ import Foundation if error != nil { completion(nil, error) return - } else if response?.statusCode != 200 { + } else if response?.statusCode != 200 || body == nil { completion(nil, BTAPIClientError.configurationUnavailable) return } else { - configuration = BTConfiguration(json: body) - - if http == nil { - let baseURL: URL? = configuration?.json?["clientApiUrl"].asURL() + let configuration = BTConfiguration(json: body) - if let clientToken, let baseURL { - http = BTHTTP(url: baseURL, authorizationFingerprint: clientToken.authorizationFingerprint) - } else if let tokenizationKey, let baseURL { - http = BTHTTP(url: baseURL, tokenizationKey: tokenizationKey) - } - } - - if graphQLHTTP == nil { - let graphQLBaseURL: URL? = graphQLURL(forEnvironment: configuration?.environment ?? "") - - if let clientToken, let graphQLBaseURL { - graphQLHTTP = BTGraphQLHTTP(url: graphQLBaseURL, authorizationFingerprint: clientToken.authorizationFingerprint) - } else if let tokenizationKey, let graphQLBaseURL { - graphQLHTTP = BTGraphQLHTTP(url: graphQLBaseURL, tokenizationKey: tokenizationKey) - } - } + setupHTTPCredentials(configuration) + try? ConfigurationCache.shared.putInCache(authorization: authorization, configuration: configuration) + + completion(configuration, nil) + return } - - completion(configuration, nil) } } @@ -485,4 +458,28 @@ import Foundation return graphQLHTTP } } + + // MARK: - Private Methods + + private func setupHTTPCredentials(_ configuration: BTConfiguration) { + if http == nil { + let baseURL: URL? = configuration.json?["clientApiUrl"].asURL() + + if let clientToken, let baseURL { + http = BTHTTP(url: baseURL, authorizationFingerprint: clientToken.authorizationFingerprint) + } else if let tokenizationKey, let baseURL { + http = BTHTTP(url: baseURL, tokenizationKey: tokenizationKey) + } + } + + if graphQLHTTP == nil { + let graphQLBaseURL: URL? = graphQLURL(forEnvironment: configuration.environment ?? "") + + if let clientToken, let graphQLBaseURL { + graphQLHTTP = BTGraphQLHTTP(url: graphQLBaseURL, authorizationFingerprint: clientToken.authorizationFingerprint) + } else if let tokenizationKey, let graphQLBaseURL { + graphQLHTTP = BTGraphQLHTTP(url: graphQLBaseURL, tokenizationKey: tokenizationKey) + } + } + } } diff --git a/Sources/BraintreeCore/BTAPIClientError.swift b/Sources/BraintreeCore/BTAPIClientError.swift index 9b71b00466..dffb34f80a 100644 --- a/Sources/BraintreeCore/BTAPIClientError.swift +++ b/Sources/BraintreeCore/BTAPIClientError.swift @@ -11,6 +11,9 @@ public enum BTAPIClientError: Int, Error, CustomNSError, LocalizedError, Equatab /// 2. Deallocated BTAPIClient case deallocated + + /// 3. Failed to base64 encode an authorizationFingerprint or tokenizationKey, when used as a cacheKey + case failedBase64Encoding public static var errorDomain: String { "com.braintreepayments.BTAPIClientErrorDomain" @@ -30,6 +33,9 @@ public enum BTAPIClientError: Int, Error, CustomNSError, LocalizedError, Equatab case .deallocated: return "BTAPIClient has been deallocated." + + case .failedBase64Encoding: + return "Unable to base64 encode the authorization string." } } } diff --git a/Sources/BraintreeCore/BTCacheDateValidator.swift b/Sources/BraintreeCore/BTCacheDateValidator.swift deleted file mode 100644 index 04cfb1e24e..0000000000 --- a/Sources/BraintreeCore/BTCacheDateValidator.swift +++ /dev/null @@ -1,25 +0,0 @@ -import Foundation - -struct BTCacheDateValidator { - - let timeToLiveMinutes: Double = 5 - let dateFormatter: DateFormatter = DateFormatter() - - func isCacheInvalid(_ cachedConfigurationResponse: CachedURLResponse?) -> Bool { - dateFormatter.dateFormat = "EEE',' dd' 'MMM' 'yyyy HH':'mm':'ss zzz" - - // Invalidate cached configuration after 5 minutes - let expirationTimestamp: Date = Date().addingTimeInterval(-60 * timeToLiveMinutes) - - guard let cachedResponse = cachedConfigurationResponse?.response as? HTTPURLResponse, - let cachedResponseDateString = cachedResponse.value(forHTTPHeaderField: "Date"), - let cachedResponseTimestamp: Date = dateFormatter.date(from: cachedResponseDateString) - else { - return true - } - - let earlierDate: Date = cachedResponseTimestamp <= expirationTimestamp ? cachedResponseTimestamp : expirationTimestamp - - return earlierDate == cachedResponseTimestamp - } -} diff --git a/Sources/BraintreeCore/BTConfiguration.swift b/Sources/BraintreeCore/BTConfiguration.swift index 2a0c69c052..83fbed2233 100644 --- a/Sources/BraintreeCore/BTConfiguration.swift +++ b/Sources/BraintreeCore/BTConfiguration.swift @@ -19,6 +19,10 @@ import Foundation var fptiEnvironment: String? { environment == "production" ? "live" : environment } + + /// :nodoc: Timestamp of initialization of each `BTConfiguration` instance + /// Mutable for testing. + var time = Date().timeIntervalSince1970 /// :nodoc: This initalizer is exposed for internal Braintree use only. Do not use. It is not covered by Semantic Versioning and may change or be removed at any time. /// Used to initialize a `BTConfiguration` diff --git a/Sources/BraintreeCore/BTGraphQLHTTP.swift b/Sources/BraintreeCore/BTGraphQLHTTP.swift index 99e534b87a..2414cc6ce9 100644 --- a/Sources/BraintreeCore/BTGraphQLHTTP.swift +++ b/Sources/BraintreeCore/BTGraphQLHTTP.swift @@ -10,7 +10,7 @@ class BTGraphQLHTTP: BTHTTP { // MARK: - Overrides - override func get(_ path: String, parameters: Encodable? = nil, shouldCache: Bool = false, completion: @escaping RequestCompletion) { + override func get(_ path: String, parameters: Encodable? = nil, completion: @escaping RequestCompletion) { NSException(name: exceptionName, reason: "GET is unsupported").raise() } diff --git a/Sources/BraintreeCore/BTHTTP.swift b/Sources/BraintreeCore/BTHTTP.swift index 96fc46cafc..5915e7ee46 100644 --- a/Sources/BraintreeCore/BTHTTP.swift +++ b/Sources/BraintreeCore/BTHTTP.swift @@ -18,7 +18,6 @@ class BTHTTP: NSObject, NSCopying, URLSessionDelegate { /// DispatchQueue on which asynchronous code will be executed. Defaults to `DispatchQueue.main`. var dispatchQueue: DispatchQueue = DispatchQueue.main let baseURL: URL - let cacheDateValidator: BTCacheDateValidator = BTCacheDateValidator() var clientAuthorization: ClientAuthorization? /// Session exposed for testing @@ -28,7 +27,6 @@ class BTHTTP: NSObject, NSCopying, URLSessionDelegate { let delegateQueue: OperationQueue = OperationQueue() delegateQueue.name = "com.braintreepayments.BTHTTP" - delegateQueue.maxConcurrentOperationCount = OperationQueue.defaultMaxConcurrentOperationCount return URLSession(configuration: configuration, delegate: self, delegateQueue: delegateQueue) }() @@ -99,15 +97,11 @@ class BTHTTP: NSObject, NSCopying, URLSessionDelegate { // MARK: - HTTP Methods - func get(_ path: String, parameters: Encodable? = nil, shouldCache: Bool = false, completion: @escaping RequestCompletion) { + func get(_ path: String, parameters: Encodable? = nil, completion: @escaping RequestCompletion) { do { let dict = try parameters?.toDictionary() - if shouldCache { - httpRequestWithCaching(method: "GET", path: path, parameters: dict, completion: completion) - } else { - httpRequest(method: "GET", path: path, parameters: dict, completion: completion) - } + httpRequest(method: "GET", path: path, parameters: dict, completion: completion) } catch let error { completion(nil, nil, error) } @@ -137,44 +131,6 @@ class BTHTTP: NSObject, NSCopying, URLSessionDelegate { // MARK: - HTTP Method Helpers - func httpRequestWithCaching( - method: String, - path: String, - parameters: [String: Any]? = [:], - completion: RequestCompletion? - ) { - createRequest(method: method, path: path, parameters: parameters) { request, error in - guard let request = request else { - self.handleRequestCompletion(data: nil, request: nil, shouldCache: false, response: nil, error: error, completion: completion) - return - } - - var cachedResponse: CachedURLResponse? = URLCache.shared.cachedResponse(for: request) ?? nil - - if self.cacheDateValidator.isCacheInvalid(cachedResponse ?? nil) { - URLCache.shared.removeAllCachedResponses() - cachedResponse = nil - } - - // The increase in speed of API calls with cached configuration caused an increase in "network connection lost" errors. - // Adding this delay allows us to throttle the network requests slightly to reduce load on the servers and decrease connection lost errors. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - if let cachedResponse = cachedResponse { - self.handleRequestCompletion(data: cachedResponse.data, request: nil, shouldCache: false, response: cachedResponse.response, error: nil, completion: completion) - } else { - self.session.dataTask(with: request) { [weak self] data, response, error in - guard let self else { - completion?(nil, nil, BTHTTPError.deallocated("BTHTTP")) - return - } - - handleRequestCompletion(data: data, request: request, shouldCache: true, response: response, error: error, completion: completion) - }.resume() - } - } - } - } - func httpRequest( method: String, path: String, @@ -183,17 +139,17 @@ class BTHTTP: NSObject, NSCopying, URLSessionDelegate { ) { createRequest(method: method, path: path, parameters: parameters) { request, error in guard let request = request else { - self.handleRequestCompletion(data: nil, request: nil, shouldCache: false, response: nil, error: error, completion: completion) + self.handleRequestCompletion(data: nil, request: nil, response: nil, error: error, completion: completion) return } - + self.session.dataTask(with: request) { [weak self] data, response, error in guard let self else { completion?(nil, nil, BTHTTPError.deallocated("BTHTTP")) return } - handleRequestCompletion(data: data, request: request, shouldCache: false, response: response, error: error, completion: completion) + handleRequestCompletion(data: data, request: request, response: response, error: error, completion: completion) }.resume() } } @@ -316,7 +272,6 @@ class BTHTTP: NSObject, NSCopying, URLSessionDelegate { func handleRequestCompletion( data: Data?, request: URLRequest?, - shouldCache: Bool, response: URLResponse?, error: Error?, completion: RequestCompletion? @@ -365,15 +320,6 @@ class BTHTTP: NSObject, NSCopying, URLSessionDelegate { return } - // We should only cache the response if we do not have an error and status code is 2xx - let successStatusCode: Bool = httpResponse.statusCode >= 200 && httpResponse.statusCode < 300 - - if request != nil && shouldCache && successStatusCode, let request = request { - let cachedURLResponse: CachedURLResponse = CachedURLResponse(response: response, data: data) - - URLCache.shared.storeCachedResponse(cachedURLResponse, for: request) - } - callCompletionAsync(with: completion, body: json, response: httpResponse, error: nil) } diff --git a/Sources/BraintreeCore/ConfigurationCache.swift b/Sources/BraintreeCore/ConfigurationCache.swift new file mode 100644 index 0000000000..7cf8a650a0 --- /dev/null +++ b/Sources/BraintreeCore/ConfigurationCache.swift @@ -0,0 +1,59 @@ +import Foundation + +class ConfigurationCache { + + // MARK: - Private Properties + + private let timeToLiveMinutes = 5.0 + + // MARK: - Internal Properties + + /// Exposed for testing + let cacheInstance = NSCache() + + // MARK: - Singleton Setup + + static let shared = ConfigurationCache() + private init() { } + + // MARK: - Internal Methods + + /// Adds a configuration object to the cache. + /// - Parameters: + /// - authorization: An authorizationFingerprint or tokenizationKey. + /// - configuration: A `BTConfiguration` object. + /// - Throws: An error if the authorization string cannot be base64 encoded for cache entry. + func putInCache(authorization: String, configuration: BTConfiguration) throws { + let cacheKey = try createCacheKey(authorization) + cacheInstance.setObject(configuration, forKey: cacheKey) + } + + /// Checks to see if a configuration object exists in the cache for a given authorization string. + /// - Parameter authorization: An authorizationFingerprint or tokenizationKey. + /// - Returns: A `BTConfiguration` object if present in the cache, or `nil` if not present in the cache. + /// - Throws: An error if the authorization string cannot be base64 encoded for cache lookup. + func getFromCache(authorization: String) throws -> BTConfiguration? { + let cacheKey = try createCacheKey(authorization) + guard let cachedConfig = cacheInstance.object(forKey: cacheKey) else { + return nil + } + + let timeInCache = Date().timeIntervalSince1970 - cachedConfig.time + if timeInCache < (timeToLiveMinutes * 60) { + return cachedConfig + } else { + cacheInstance.removeObject(forKey: cacheKey) + return nil + } + } + + // MARK: - Private Methods + + private func createCacheKey(_ authorization: String) throws -> NSString { + if let data = authorization.data(using: .utf8) { + return data.base64EncodedString() as NSString + } else { + throw BTAPIClientError.failedBase64Encoding + } + } +} diff --git a/UnitTests/BraintreeCoreTests/BTAPIClient_Tests.swift b/UnitTests/BraintreeCoreTests/BTAPIClient_Tests.swift index 5bf4930625..e30ca82f02 100644 --- a/UnitTests/BraintreeCoreTests/BTAPIClient_Tests.swift +++ b/UnitTests/BraintreeCoreTests/BTAPIClient_Tests.swift @@ -7,6 +7,7 @@ class BTAPIClient_Tests: XCTestCase { override func setUp() { super.setUp() + ConfigurationCache.shared.cacheInstance.removeAllObjects() mockConfigurationHTTP.stubRequest(withMethod: "GET", toEndpoint: "/client_api/v1/configuration", respondWith: [] as [Any?], statusCode: 200) } @@ -60,6 +61,24 @@ class BTAPIClient_Tests: XCTestCase { } // MARK: - fetchOrReturnRemoteConfiguration + + func testFetchOrReturnRemoteConfiguration_whenCached_returnsConfigFromCache() { + let sampleJSON = ["test": "value", "environment": "fake-env1"] + try? ConfigurationCache.shared.putInCache(authorization: "development_tokenization_key", configuration: BTConfiguration(json: BTJSON(value: sampleJSON))) + let mockHTTP = FakeHTTP.fakeHTTP() + + let apiClient = BTAPIClient(authorization: "development_tokenization_key") + apiClient?.http = mockHTTP + + let expectation = expectation(description: "Callback invoked") + apiClient?.fetchOrReturnRemoteConfiguration() { configuration, error in + XCTAssertEqual(configuration?.environment, "fake-env1") + XCTAssertEqual(configuration?.json?["test"].asString(), "value") + XCTAssertNil(mockHTTP.lastRequestEndpoint) + expectation.fulfill() + } + waitForExpectations(timeout: 1) + } func testFetchOrReturnRemoteConfiguration_performsGETWithCorrectPayload() { let apiClient = BTAPIClient(authorization: "development_testing_integration_merchant_id") @@ -123,6 +142,7 @@ class BTAPIClient_Tests: XCTestCase { } func testConfiguration_whenNetworkHasError_returnsNetworkErrorInCallback() { + ConfigurationCache.shared.cacheInstance.removeAllObjects() let apiClient = BTAPIClient(authorization: "development_tokenization_key") let mockHTTP = FakeHTTP.fakeHTTP() let mockError: NSError = NSError(domain: NSURLErrorDomain, code: NSURLErrorCannotConnectToHost) @@ -142,23 +162,14 @@ class BTAPIClient_Tests: XCTestCase { waitForExpectations(timeout: 1) } - func testConfigurationHTTP_byDefault_usesAnInMemoryCache() { - // We don't want configuration to cache configuration responses past the lifetime of the app - let apiClient = BTAPIClient(authorization: "development_tokenization_key") - guard let cache: URLCache = apiClient?.configurationHTTP?.session.configuration.urlCache else { return } - - XCTAssertTrue(cache.diskCapacity == 0) - XCTAssertTrue(cache.memoryCapacity > 0) - } - // MARK: - fetchPaymentMethodNonces with v2 client token - + func testFetchPaymentMethodNonces_performsGETWithCorrectParameter() { let apiClient = BTAPIClient(authorization: TestClientTokenFactory.validClientToken) let mockHTTP = FakeHTTP.fakeHTTP() - apiClient?.configurationHTTP = mockConfigurationHTTP - mockHTTP.stubRequest(withMethod: "GET", toEndpoint: "/client_api/v1/payment_methods", respondWith: [] as [Any?], statusCode: 200) + mockHTTP.cannedConfiguration = BTJSON(value: ["test": true]) + mockHTTP.cannedStatusCode = 200 apiClient?.http = mockHTTP let expectation = expectation(description: "Callback invoked") @@ -176,9 +187,10 @@ class BTAPIClient_Tests: XCTestCase { let apiClient = BTAPIClient(authorization: TestClientTokenFactory.validClientToken) let mockHTTP = FakeHTTP.fakeHTTP() - apiClient?.configurationHTTP = mockConfigurationHTTP - mockHTTP.stubRequest(withMethod: "GET", toEndpoint: "/client_api/v1/payment_methods", respondWith: [] as [Any?], statusCode: 200) + apiClient?.configurationHTTP = mockHTTP apiClient?.http = mockHTTP + mockHTTP.cannedConfiguration = BTJSON(value: ["test": true]) + mockHTTP.cannedStatusCode = 200 let expectation = expectation(description: "Callback invoked") apiClient?.fetchPaymentMethodNonces(true) { _,_ in @@ -194,9 +206,10 @@ class BTAPIClient_Tests: XCTestCase { let apiClient = BTAPIClient(authorization: TestClientTokenFactory.validClientToken) let mockHTTP = FakeHTTP.fakeHTTP() - apiClient?.configurationHTTP = mockConfigurationHTTP - mockHTTP.stubRequest(withMethod: "GET", toEndpoint: "/client_api/v1/payment_methods", respondWith: [] as [Any?], statusCode: 200) + apiClient?.configurationHTTP = mockHTTP apiClient?.http = mockHTTP + mockHTTP.cannedConfiguration = BTJSON(value: ["test": true]) + mockHTTP.cannedStatusCode = 200 let expectation = expectation(description: "Callback invoked") apiClient?.fetchPaymentMethodNonces(false) { _,_ in @@ -232,7 +245,7 @@ class BTAPIClient_Tests: XCTestCase { ] ] - apiClient?.configurationHTTP = mockConfigurationHTTP + apiClient?.http = mockConfigurationHTTP stubHTTP.stubRequest(withMethod: "GET", toEndpoint: "/client_api/v1/payment_methods", respondWith: stubbedResponse, statusCode: 200) apiClient?.http = stubHTTP @@ -349,7 +362,8 @@ class BTAPIClient_Tests: XCTestCase { // Override apiClient.http so that requests don't fail apiClient?.configurationHTTP = mockHTTP apiClient?.http = mockHTTP - mockHTTP.stubRequest(withMethod: "GET", toEndpoint: "/client_api/v1/configuration", respondWith: [] as [Any?], statusCode: 200) + mockHTTP.cannedConfiguration = BTJSON(value: ["test": true]) + mockHTTP.cannedStatusCode = 200 let expectation1 = expectation(description: "Fetch configuration") apiClient?.fetchOrReturnRemoteConfiguration() { _, _ in @@ -406,9 +420,10 @@ class BTAPIClient_Tests: XCTestCase { let mockHTTP = FakeHTTP.fakeHTTP() let metadata = apiClient?.metadata - apiClient?.http = mockHTTP apiClient?.configurationHTTP = mockHTTP - mockHTTP.stubRequest(withMethod: "GET", toEndpoint: "/client_api/v1/configuration", respondWith: [] as [Any?], statusCode: 200) + apiClient?.http = mockHTTP + mockHTTP.cannedConfiguration = BTJSON(value: ["test": true]) + mockHTTP.cannedStatusCode = 200 let expectation = expectation(description: "POST callback") apiClient?.post("/", parameters: [:], httpType: .gateway) { _, _, _ in @@ -436,7 +451,9 @@ class BTAPIClient_Tests: XCTestCase { apiClient?.graphQLHTTP = mockGraphQLHTTP apiClient?.configurationHTTP = mockHTTP - mockHTTP.stubRequest(withMethod: "GET", toEndpoint: "/client_api/v1/configuration", respondWith: mockResponse, statusCode: 200) + apiClient?.http = mockHTTP + mockHTTP.cannedConfiguration = BTJSON(value: ["test": true]) + mockHTTP.cannedStatusCode = 200 let expectation = expectation(description: "POST callback") apiClient?.post("/", parameters: [:], httpType: .graphQLAPI) { _, _, _ in @@ -455,9 +472,10 @@ class BTAPIClient_Tests: XCTestCase { let mockHTTP = FakeHTTP.fakeHTTP() let metadata = apiClient?.metadata - apiClient?.http = mockHTTP apiClient?.configurationHTTP = mockHTTP - mockHTTP.stubRequest(withMethod: "GET", toEndpoint: "/client_api/v1/configuration", respondWith: [] as [Any?], statusCode: 200) + apiClient?.http = mockHTTP + mockHTTP.cannedConfiguration = BTJSON(value: ["test": true]) + mockHTTP.cannedStatusCode = 200 let postParameters = FakeRequest(testValue: "fake-value") @@ -489,7 +507,9 @@ class BTAPIClient_Tests: XCTestCase { apiClient?.graphQLHTTP = mockGraphQLHTTP apiClient?.configurationHTTP = mockHTTP - mockHTTP.stubRequest(withMethod: "GET", toEndpoint: "/client_api/v1/configuration", respondWith: mockResponse, statusCode: 200) + apiClient?.http = mockHTTP + mockHTTP.cannedConfiguration = BTJSON(value: ["test": true]) + mockHTTP.cannedStatusCode = 200 let postParameters = FakeRequest(testValue: "fake-value") diff --git a/UnitTests/BraintreeCoreTests/BTCacheDateValidator_Tests.swift b/UnitTests/BraintreeCoreTests/BTCacheDateValidator_Tests.swift deleted file mode 100644 index 9919f4a11c..0000000000 --- a/UnitTests/BraintreeCoreTests/BTCacheDateValidator_Tests.swift +++ /dev/null @@ -1,9 +0,0 @@ -import XCTest -@testable import BraintreeCore - -class BTCacheDateValidator_Tests: XCTestCase { - func testTimeToLiveMinutes_defaultsTo5() { - let cacheDateValidator = BTCacheDateValidator() - XCTAssertEqual(5, cacheDateValidator.timeToLiveMinutes) - } -} diff --git a/UnitTests/BraintreeCoreTests/BTHTTP_Tests.swift b/UnitTests/BraintreeCoreTests/BTHTTP_Tests.swift index 0b2d728eb0..13d52fed09 100644 --- a/UnitTests/BraintreeCoreTests/BTHTTP_Tests.swift +++ b/UnitTests/BraintreeCoreTests/BTHTTP_Tests.swift @@ -395,44 +395,6 @@ final class BTHTTP_Tests: XCTestCase { waitForExpectations(timeout: 2) } - // MARK: - Configuration tests - - func testGETRequests_whenShouldCache_cachesConfiguration() { - URLCache.shared.removeAllCachedResponses() - let expectation = expectation(description: "Fetches configuration") - - http?.get("/configuration", parameters: ["configVersion": "3"], shouldCache: true) { body, response, error in - XCTAssertNotNil(body) - XCTAssertNotNil(response) - XCTAssertNil(error) - - let httpRequest = BTHTTPTestProtocol.parseRequestFromTestResponseBody(body!) - XCTAssertNotNil(URLCache.shared.cachedResponse(for: httpRequest)) - expectation.fulfill() - } - - waitForExpectations(timeout: 2) - URLCache.shared.removeAllCachedResponses() - } - - func testGETRequests_whenShouldNotCache_doesNotStoreInCache() { - URLCache.shared.removeAllCachedResponses() - let expectation = expectation(description: "Fetches configuration") - - http?.get("/configuration", parameters: ["configVersion": "3"], shouldCache: false) { body, response, error in - XCTAssertNotNil(body) - XCTAssertNotNil(response) - XCTAssertNil(error) - - let httpRequest = BTHTTPTestProtocol.parseRequestFromTestResponseBody(body!) - XCTAssertNil(URLCache.shared.cachedResponse(for: httpRequest)) - expectation.fulfill() - } - - waitForExpectations(timeout: 2) - URLCache.shared.removeAllCachedResponses() - } - // MARK: - Authentication func testGETRequests_whenBTHTTPInitializedWithAuthorizationFingerprint_sendAuthorizationInQueryParams() { diff --git a/UnitTests/BraintreeCoreTests/ConfigurationCache_Tests.swift b/UnitTests/BraintreeCoreTests/ConfigurationCache_Tests.swift new file mode 100644 index 0000000000..6de03fe431 --- /dev/null +++ b/UnitTests/BraintreeCoreTests/ConfigurationCache_Tests.swift @@ -0,0 +1,44 @@ +import XCTest +@testable import BraintreeCore + +class ConfigurationCache_Tests: XCTestCase { + + private let sut = ConfigurationCache.shared + private var fakeConfiguration: BTConfiguration! + private let base64EndodedCat: NSString = "Y2F0" + private let base64EncodedDog: NSString = "ZG9n" + + override func setUp() { + sut.cacheInstance.removeAllObjects() + fakeConfiguration = BTConfiguration(json: BTJSON(value: ["test": "value", "environment": "fake-env1"])) + } + + func testPutInCache_cachesItemWithBase64EncodedKey() throws { + try sut.putInCache(authorization: "dog", configuration: fakeConfiguration) + + XCTAssertEqual(sut.cacheInstance.object(forKey: base64EncodedDog), fakeConfiguration) + } + + func testPutInCache_whenBase64EncodingFails_throwsError() { + do { + try sut.putInCache(authorization: "💇‍♀️", configuration: fakeConfiguration) + } catch { + XCTAssertEqual(error.localizedDescription, "Unable to base64 encode the authorization string.") + } + } + + func testGetFromCache_ifCachedItemExpired_returnsNil() throws { + fakeConfiguration.time = Date().timeIntervalSince1970 - 301 // 5 minutes, and 1 second ago + sut.cacheInstance.setObject(fakeConfiguration, forKey: base64EndodedCat) + + XCTAssertNil(try sut.getFromCache(authorization: "cat")) + } + + func testGetFromCache_ifCachedItemNotExpired_returnsItem() throws { + fakeConfiguration.time = Date().timeIntervalSince1970 - 299 // 4 minutes, and 59 second ago + sut.cacheInstance.setObject(fakeConfiguration, forKey: base64EndodedCat) + + let cachedItem = try sut.getFromCache(authorization: "cat") + XCTAssertEqual(cachedItem, fakeConfiguration) + } +} diff --git a/UnitTests/BraintreeTestShared/FakeHTTP.swift b/UnitTests/BraintreeTestShared/FakeHTTP.swift index 516e9accff..ba28c272c3 100644 --- a/UnitTests/BraintreeTestShared/FakeHTTP.swift +++ b/UnitTests/BraintreeTestShared/FakeHTTP.swift @@ -35,7 +35,7 @@ import Foundation cannedError = error } - public override func get(_ path: String, parameters: Encodable? = nil, shouldCache: Bool, completion: BTHTTP.RequestCompletion?) { + public override func get(_ path: String, parameters: Encodable? = nil, completion: BTHTTP.RequestCompletion?) { GETRequestCount += 1 lastRequestEndpoint = path lastRequestParameters = try? parameters?.toDictionary()