Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Cache configuration for a given ClientSession #959

Merged
merged 38 commits into from
Aug 8, 2024
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
9d11732
Added poc of config caching
NQuinn27 Jul 23, 2024
29d48dc
Extend network service to bubble up headers when wanted
NQuinn27 Jul 31, 2024
b29480d
Dont utilise headers in klarna configs
NQuinn27 Jul 31, 2024
daae31c
Added Swift-friendly Cache
NQuinn27 Jul 31, 2024
0e7114c
Refactored caching logic to dedicated class
NQuinn27 Jul 31, 2024
da839d8
use refactored cache
NQuinn27 Jul 31, 2024
f9d54db
Extract ttl from headers
NQuinn27 Aug 1, 2024
21a438e
Process images also for actions
NQuinn27 Aug 1, 2024
5d3c7e4
Upgrade config endpoints to 2.3
NQuinn27 Aug 1, 2024
f4e130e
Add config cache tests
NQuinn27 Aug 1, 2024
64f86ce
Fix tests for new interface
NQuinn27 Aug 1, 2024
661f8de
update fallback to 0
NQuinn27 Aug 1, 2024
25220ff
add tests for config module
NQuinn27 Aug 1, 2024
228c930
Add responses to mocks
NQuinn27 Aug 1, 2024
3558a8c
Formatting: Update ConfigurationCache.swift
NQuinn27 Aug 1, 2024
9787b5f
Formatting: Update PrimerAPIConfigurationModule.swift
NQuinn27 Aug 1, 2024
4b75f7b
Formatting: Update PrimerAPIConfigurationModule.swift
NQuinn27 Aug 1, 2024
cf0b551
Formatting: Update DefaultNetworkService.swift
NQuinn27 Aug 1, 2024
f9915e2
Formatting: Update NetworkService.swift
NQuinn27 Aug 1, 2024
06048cb
Merge branch 'master' into nq/cache_configuration
NQuinn27 Aug 1, 2024
6ff4a56
Clear cache after tests
NQuinn27 Aug 1, 2024
e0ad8b4
Update apiVersion in tests
NQuinn27 Aug 2, 2024
3ff3058
Added events for loading
NQuinn27 Aug 5, 2024
ca93d3e
Clear cache on cleanup
NQuinn27 Aug 5, 2024
5de40a6
Add checkout session active
NQuinn27 Aug 5, 2024
6c03ba0
Record load of vault manager
NQuinn27 Aug 5, 2024
b457de7
Merge branch 'master' into nq/cache_configuration
NQuinn27 Aug 5, 2024
5914d17
Record headless loading event
NQuinn27 Aug 5, 2024
5e887f9
Merge branch 'master' into nq/cache_configuration
NQuinn27 Aug 5, 2024
9e43a6f
fix: Validate PENDING in resume if showSuccessCheckoutOnPendingPaymen…
NQuinn27 Aug 6, 2024
4352203
Fix conflicts
NQuinn27 Aug 7, 2024
0d4e825
Release 2.28.0 (#963)
primer-security-integrations Aug 6, 2024
dcc5998
Add clientSessionCachingEnabled flag
NQuinn27 Aug 7, 2024
56c8a10
Add unit test to test for flag
NQuinn27 Aug 7, 2024
0e20c49
move cache clean to cleanup
NQuinn27 Aug 7, 2024
1df039a
Merge branch 'master' into nq/cache_configuration
NQuinn27 Aug 7, 2024
68e7282
Implement LogReporter
NQuinn27 Aug 7, 2024
9663e6d
Fix use cached config test
NQuinn27 Aug 7, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions Sources/PrimerSDK/Classes/Core/Cache/Cache.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
//
// Cache.swift
// PrimerSDK
//
// Created by Niall Quinn on 31/07/24.
//

import Foundation

class Cache<Key: Hashable, Value> {
private let cache = NSCache<WrappedKey, Entry>()

func insert(_ value: Value, forKey key: Key) {
cache.setObject(Entry(value: value), forKey: WrappedKey(key))
}

func value(forKey key: Key) -> Value? {
guard let entry = cache.object(forKey: WrappedKey(key)) else {
return nil
}
return entry.value
}

func removeValue(forKey key: Key) {
cache.removeObject(forKey: WrappedKey(key))
}
}

private extension Cache {
final class WrappedKey: NSObject {
let key: Key
init(_ key: Key) { self.key = key }

override var hash: Int { return key.hashValue }

override func isEqual(_ object: Any?) -> Bool {
guard let value = object as? WrappedKey else {
return false
}
return value.key == key
}
}
}

private extension Cache {
final class Entry {
let value: Value
init(value: Value) { self.value = value }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
//
// ConfigurationCache.swift
// PrimerSDK
//
// Created by Niall Quinn on 31/07/24.
//

import Foundation

protocol ConfigurationCaching {
func clearCache()
func setData(_ data: ConfigurationCachedData, forKey key: String)
func data(forKey key: String) -> ConfigurationCachedData?
}

class ConfigurationCache: ConfigurationCaching {
static let shared = ConfigurationCache()
private var cache = Cache<String, ConfigurationCachedData>()

func clearCache() {
cache = Cache<String, ConfigurationCachedData>()
}

func data(forKey key: String) -> ConfigurationCachedData? {
if let cachedData = cache.value(forKey: key) {
if validateCachedConfig(key: key, cachedData: cachedData) == false {
cache.removeValue(forKey: key)
return nil
}
return cachedData
}
return nil
}

func setData(_ data: ConfigurationCachedData, forKey key: String) {
// Cache includes at most one cached configuration
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a potential case for having a SingleItemCache wrapper around NSCache instead? Then the key could be passed in the init and removed from all the upstream methods here

My concern would be someone seeing this and thinking that it supports multiple configurations because of the provision of a key

clearCache()
cache.insert(data, forKey: key)
}

private func validateCachedConfig(key: String, cachedData: ConfigurationCachedData) -> Bool {
let timestamp = cachedData.timestamp
let now = Date().timeIntervalSince1970
let timeInterval = now - timestamp

if timeInterval > cachedData.ttl {
return false
}

return true
}
}

class ConfigurationCachedData {

let config: PrimerAPIConfiguration
let timestamp: TimeInterval
let ttl: TimeInterval

init(config: PrimerAPIConfiguration, headers: [String: String]? = nil) {
//Extract ttl from headers
self.config = config
self.timestamp = Date().timeIntervalSince1970
self.ttl = Self.extractTtlFromHeaders(headers)
}

static let FallbackCacheExpiration: TimeInterval = 0
static let CacheHeaderKey = "x-primer-session-cache-ttl"

private static func extractTtlFromHeaders(_ headers: [String: String]?) -> TimeInterval {
guard let headers,
let ttlHeaderValue = headers[Self.CacheHeaderKey],
let ttlInt = Int(ttlHeaderValue) else {
return Self.FallbackCacheExpiration
}
return TimeInterval(ttlInt)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ internal protocol PrimerAPIConfigurationModuleProtocol {
static var clientToken: JWTToken? { get }
static var decodedJWTToken: DecodedJWTToken? { get }
static var apiConfiguration: PrimerAPIConfiguration? { get }

static func resetSession()

func setupSession(
Expand All @@ -25,6 +26,11 @@ internal class PrimerAPIConfigurationModule: PrimerAPIConfigurationModuleProtoco

static var apiClient: PrimerAPIClientProtocol?

private static let queue = DispatchQueue(label: "com.primer.configurationQueue")
private static var pendingPromises: [String: Promise<PrimerAPIConfiguration>] = [:]

private let logger = PrimerLogging.shared.logger
NQuinn27 marked this conversation as resolved.
Show resolved Hide resolved

static var clientToken: JWTToken? {
get {
if PrimerAPIConfigurationModule.decodedJWTToken == nil {
Expand Down Expand Up @@ -66,6 +72,13 @@ internal class PrimerAPIConfigurationModule: PrimerAPIConfigurationModuleProtoco
return decodedJWTToken
}

static var cacheKey: String? {
guard let cacheKey = Self.clientToken else {
return nil
}
return cacheKey
}

static func resetSession() {
AppState.current.clientToken = nil
AppState.current.apiConfiguration = nil
Expand Down Expand Up @@ -102,7 +115,8 @@ internal class PrimerAPIConfigurationModule: PrimerAPIConfigurationModuleProtoco

func updateSession(withActions actionsRequest: ClientSessionUpdateRequest) -> Promise<Void> {
return Promise { seal in
guard let decodedJWTToken = PrimerAPIConfigurationModule.decodedJWTToken else {
guard let decodedJWTToken = PrimerAPIConfigurationModule.decodedJWTToken,
let cacheKey = Self.cacheKey else {
let err = PrimerError.invalidClientToken(userInfo: .errorUserInfoDictionary(),
diagnosticsId: UUID().uuidString)
ErrorHandler.handle(error: err)
Expand All @@ -112,11 +126,15 @@ internal class PrimerAPIConfigurationModule: PrimerAPIConfigurationModuleProtoco

let apiClient: PrimerAPIClientProtocol = PrimerAPIConfigurationModule.apiClient ?? PrimerAPIClient()
apiClient.requestPrimerConfigurationWithActions(clientToken: decodedJWTToken,
request: actionsRequest) { result in
request: actionsRequest) { result, responseHeaders in
switch result {
case .success(let configuration):
PrimerAPIConfigurationModule.apiConfiguration?.clientSession = configuration.clientSession
seal.fulfill()
_ = ImageFileProcessor().process(configuration: configuration).ensure {
PrimerAPIConfigurationModule.apiConfiguration?.clientSession = configuration.clientSession
let cachedData = ConfigurationCachedData(config: configuration, headers: responseHeaders)
ConfigurationCache.shared.setData(cachedData, forKey: cacheKey)
seal.fulfill()
}
case .failure(let err):
seal.reject(err)
}
Expand Down Expand Up @@ -244,28 +262,72 @@ internal class PrimerAPIConfigurationModule: PrimerAPIConfigurationModuleProtoco
}
}

// swiftlint:disable:next function_body_length
private func fetchConfiguration(requestDisplayMetadata: Bool) -> Promise<PrimerAPIConfiguration> {
return Promise { seal in
guard let clientToken = PrimerAPIConfigurationModule.decodedJWTToken else {
guard let clientToken = PrimerAPIConfigurationModule.decodedJWTToken,
let cacheKey = Self.cacheKey else {
let err = PrimerError.invalidClientToken(userInfo: .errorUserInfoDictionary(),
diagnosticsId: UUID().uuidString)
ErrorHandler.handle(error: err)
seal.reject(err)
return
}

let requestParameters = Request.URLParameters.Configuration(
skipPaymentMethodTypes: [],
requestDisplayMetadata: requestDisplayMetadata)
PrimerAPIConfigurationModule.queue.sync {
if let cachedConfig = ConfigurationCache.shared.data(forKey: cacheKey) {
let event = Analytics.Event.message(
message: "Configuration cache hit with key: \(cacheKey)",
messageType: .info,
severity: .info
)
Analytics.Service.record(event: event)
logger.debug(message: "Cached config used")
seal.fulfill(cachedConfig.config)
return
}

let apiClient: PrimerAPIClientProtocol = PrimerAPIConfigurationModule.apiClient ?? PrimerAPIClient()
apiClient.fetchConfiguration(clientToken: clientToken, requestParameters: requestParameters) { (result) in
switch result {
case .failure(let err):
seal.reject(err)
case .success(let config):
_ = ImageFileProcessor().process(configuration: config).ensure {
if let pendingPromise = PrimerAPIConfigurationModule.pendingPromises[cacheKey as String] {
pendingPromise.done { config in
seal.fulfill(config)
}.catch { error in
seal.reject(error)
}
return
}

let promise = Promise<PrimerAPIConfiguration> { innerSeal in
let requestParameters = Request.URLParameters.Configuration(
skipPaymentMethodTypes: [],
requestDisplayMetadata: requestDisplayMetadata)

let apiClient: PrimerAPIClientProtocol = PrimerAPIConfigurationModule.apiClient ?? PrimerAPIClient()
apiClient.fetchConfiguration(clientToken: clientToken, requestParameters: requestParameters) { (result, responseHeaders) in
switch result {
case .failure(let err):
innerSeal.reject(err)
case .success(let config):
_ = ImageFileProcessor().process(configuration: config).ensure {
// Cache the result
let cachedData = ConfigurationCachedData(config: config, headers: responseHeaders)
ConfigurationCache.shared.setData(cachedData, forKey: cacheKey)
innerSeal.fulfill(config)
}
}
}
}

PrimerAPIConfigurationModule.pendingPromises[cacheKey as String] = promise

promise.done { config in
seal.fulfill(config)
}.catch { error in
seal.reject(error)
}

promise.ensure {
PrimerAPIConfigurationModule.queue.async {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suspect there is a small chance here that you could end up adding a callback here that is never returned because it was added after ensure was called but prior to the queued item completing.

I don't think this will play out in practice but worth thinking of whether there's a way to further streamline the promise-nesting to avoid this in the future. (Which is admittedly easier said than done!)

PrimerAPIConfigurationModule.pendingPromises.removeValue(forKey: cacheKey as String)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ private extension KlarnaTokenizationComponent {
private func requestPrimerConfiguration(decodedJWTToken: DecodedJWTToken, request: ClientSessionUpdateRequest) -> Promise<Void> {
return Promise { seal in
apiClient.requestPrimerConfigurationWithActions(clientToken: decodedJWTToken,
request: request) { [weak self] result in
request: request) { [weak self] result, _ in
guard let self = self else { return }
switch result {
case .success(let configuration):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import Foundation
import PassKit

typealias PrimerAPIConfiguration = Response.Body.Configuration
typealias PrimerAPIConfigurationResponse = (config: Response.Body.Configuration, ttl: TimeInterval)

extension Request.URLParameters {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ internal extension PrimerAPI {
case .exchangePaymentMethodToken:
tmpHeaders["X-Api-Version"] = "2.2"
case .fetchConfiguration:
tmpHeaders["X-Api-Version"] = "2.2"
tmpHeaders["X-Api-Version"] = "2.3"
case .fetchVaultedPaymentMethods:
tmpHeaders["X-Api-Version"] = "2.2"
case .deleteVaultedPaymentMethod:
Expand All @@ -190,7 +190,7 @@ internal extension PrimerAPI {
case .listRetailOutlets:
break
case .requestPrimerConfigurationWithActions:
tmpHeaders["X-Api-Version"] = "2.2"
tmpHeaders["X-Api-Version"] = "2.3"
case .begin3DSRemoteAuth:
tmpHeaders["X-Api-Version"] = "2.1"
case .continue3DSRemoteAuth:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,7 @@ class DefaultNetworkService: NetworkService, LogReporter {
self.reportingService = DefaultNetworkReportingService(analyticsService: analyticsService)
}

func request<T>(_ endpoint: Endpoint, completion: @escaping ResponseCompletion<T>) -> PrimerCancellable? where T: Decodable {

func request<T>(_ endpoint: any Endpoint, completion: @escaping ResponseCompletionWithHeaders<T>) -> (any PrimerCancellable)? where T : Decodable {
NQuinn27 marked this conversation as resolved.
Show resolved Hide resolved
do {
let request = try requestFactory.request(for: endpoint)

Expand All @@ -76,7 +75,7 @@ class DefaultNetworkService: NetworkService, LogReporter {
case .success(let theResponse):
response = theResponse
case .failure(let error):
completion(.failure(error))
completion(.failure(error), nil)
return
}

Expand All @@ -87,29 +86,36 @@ class DefaultNetworkService: NetworkService, LogReporter {
if let error = response.error {
completion(.failure(InternalError.underlyingErrors(errors: [error],
userInfo: .errorUserInfoDictionary(),
diagnosticsId: UUID().uuidString)))
diagnosticsId: UUID().uuidString)), nil)
return
}

self.logger.debug(message: response.metadata.description)
guard let data = response.data else {
completion(.failure(InternalError.noData(userInfo: .errorUserInfoDictionary(),
diagnosticsId: UUID().uuidString)))
diagnosticsId: UUID().uuidString)), nil)
return
}

do {
let responseHeaders = response.metadata.headers
let response: T = try endpoint.responseFactory.model(for: data, forMetadata: response.metadata)
completion(.success(response))
completion(.success(response), responseHeaders)
} catch {
completion(.failure(error))
completion(.failure(error), nil)
}

}
} catch {
ErrorHandler.handle(error: error)
completion(.failure(error))
completion(.failure(error), nil)
return nil
}
}

func request<T>(_ endpoint: Endpoint, completion: @escaping ResponseCompletion<T>) -> PrimerCancellable? where T: Decodable {
request(endpoint) { result, _ in
completion(result)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,12 @@
import Foundation

typealias ResponseCompletion<T> = (Result<T, Error>) -> Void
typealias ResponseCompletionWithHeaders<T> = (Result<T, Error>, [String: String]?) -> Void

internal protocol NetworkService {
@discardableResult
func request<T: Decodable>(_ endpoint: Endpoint, completion: @escaping ResponseCompletion<T>) -> PrimerCancellable?

@discardableResult
func request<T: Decodable>(_ endpoint: Endpoint, completion: @escaping ResponseCompletionWithHeaders<T>) -> PrimerCancellable?
}
Loading
Loading