Skip to content

Commit

Permalink
feat: Store and use e-tag header between SDK initializations (#268)
Browse files Browse the repository at this point in the history
  • Loading branch information
keelerm84 committed Dec 29, 2023
1 parent 40a5d01 commit 701aaa8
Show file tree
Hide file tree
Showing 20 changed files with 370 additions and 303 deletions.
3 changes: 2 additions & 1 deletion ContractTests/Source/Controllers/SdkController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ final class SdkController: RouteCollection {
"strongly-typed",
"tags",
"user-type",
"context-comparison"
"context-comparison",
"etag-caching"
]

return StatusResponse(
Expand Down
36 changes: 18 additions & 18 deletions LaunchDarkly/GeneratedCode/mocks.generated.swift
Original file line number Diff line number Diff line change
Expand Up @@ -262,24 +262,24 @@ final class FeatureFlagCachingMock: FeatureFlagCaching {
}
}

var retrieveFeatureFlagsCallCount = 0
var retrieveFeatureFlagsCallback: (() throws -> Void)?
var retrieveFeatureFlagsReceivedContextKey: String?
var retrieveFeatureFlagsReturnValue: StoredItems?
func retrieveFeatureFlags(contextKey: String) -> StoredItems? {
retrieveFeatureFlagsCallCount += 1
retrieveFeatureFlagsReceivedContextKey = contextKey
try! retrieveFeatureFlagsCallback?()
return retrieveFeatureFlagsReturnValue
}

var storeFeatureFlagsCallCount = 0
var storeFeatureFlagsCallback: (() throws -> Void)?
var storeFeatureFlagsReceivedArguments: (storedItems: StoredItems, contextKey: String, lastUpdated: Date)?
func storeFeatureFlags(_ storedItems: StoredItems, contextKey: String, lastUpdated: Date) {
storeFeatureFlagsCallCount += 1
storeFeatureFlagsReceivedArguments = (storedItems: storedItems, contextKey: contextKey, lastUpdated: lastUpdated)
try! storeFeatureFlagsCallback?()
var getCachedDataCallCount = 0
var getCachedDataCallback: (() throws -> Void)?
var getCachedDataReceivedCacheKey: String?
var getCachedDataReturnValue: (items: StoredItems?, etag: String?)!
func getCachedData(cacheKey: String) -> (items: StoredItems?, etag: String?) {
getCachedDataCallCount += 1
getCachedDataReceivedCacheKey = cacheKey
try! getCachedDataCallback?()
return getCachedDataReturnValue
}

var saveCachedDataCallCount = 0
var saveCachedDataCallback: (() throws -> Void)?
var saveCachedDataReceivedArguments: (storedItems: StoredItems, cacheKey: String, lastUpdated: Date, etag: String?)?
func saveCachedData(_ storedItems: StoredItems, cacheKey: String, lastUpdated: Date, etag: String?) {
saveCachedDataCallCount += 1
saveCachedDataReceivedArguments = (storedItems: storedItems, cacheKey: cacheKey, lastUpdated: lastUpdated, etag: etag)
try! saveCachedDataCallback?()
}
}

Expand Down
21 changes: 12 additions & 9 deletions LaunchDarkly/LaunchDarkly/LDClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -314,12 +314,13 @@ public class LDClient {
let wasOnline = self.isOnline
self.internalSetOnline(false)

let cachedContextFlags = self.flagCache.retrieveFeatureFlags(contextKey: self.context.fullyQualifiedHashedKey()) ?? [:]
let cachedData = self.flagCache.getCachedData(cacheKey: self.context.contextHash())
let cachedContextFlags = cachedData.items ?? [:]
let oldItems = flagStore.storedItems.featureFlags
flagStore.replaceStore(newStoredItems: cachedContextFlags)
flagChangeNotifier.notifyObservers(oldFlags: oldItems, newFlags: flagStore.storedItems.featureFlags)
self.service.context = self.context
self.service.clearFlagResponseCache()
self.service.resetFlagResponseCache(etag: cachedData.etag)
flagSynchronizer = serviceFactory.makeFlagSynchronizer(streamingMode: ConnectionInformation.effectiveStreamingMode(config: config, ldClient: self),
pollingInterval: config.flagPollingInterval(runMode: runMode),
useReport: config.useReport,
Expand Down Expand Up @@ -492,21 +493,21 @@ public class LDClient {
private func onFlagSyncComplete(result: FlagSyncResult) {
Log.debug(typeName(and: #function) + "result: \(result)")
switch result {
case let .flagCollection(flagCollection):
case let .flagCollection((flagCollection, etag)):
let oldStoredItems = flagStore.storedItems
connectionInformation = ConnectionInformation.checkEstablishingStreaming(connectionInformation: connectionInformation)
flagStore.replaceStore(newStoredItems: StoredItems(items: flagCollection.flags))
self.updateCacheAndReportChanges(context: self.context, oldStoredItems: oldStoredItems)
self.updateCacheAndReportChanges(context: self.context, oldStoredItems: oldStoredItems, etag: etag)
case let .patch(featureFlag):
let oldStoredItems = flagStore.storedItems
connectionInformation = ConnectionInformation.checkEstablishingStreaming(connectionInformation: connectionInformation)
flagStore.updateStore(updatedFlag: featureFlag)
self.updateCacheAndReportChanges(context: self.context, oldStoredItems: oldStoredItems)
self.updateCacheAndReportChanges(context: self.context, oldStoredItems: oldStoredItems, etag: nil)
case let .delete(deleteResponse):
let oldStoredItems = flagStore.storedItems
connectionInformation = ConnectionInformation.checkEstablishingStreaming(connectionInformation: connectionInformation)
flagStore.deleteFlag(deleteResponse: deleteResponse)
self.updateCacheAndReportChanges(context: self.context, oldStoredItems: oldStoredItems)
self.updateCacheAndReportChanges(context: self.context, oldStoredItems: oldStoredItems, etag: nil)
case .upToDate:
connectionInformation.lastKnownFlagValidity = Date()
flagChangeNotifier.notifyUnchanged()
Expand All @@ -524,8 +525,8 @@ public class LDClient {
}

private func updateCacheAndReportChanges(context: LDContext,
oldStoredItems: StoredItems) {
flagCache.storeFeatureFlags(flagStore.storedItems, contextKey: context.fullyQualifiedHashedKey(), lastUpdated: Date())
oldStoredItems: StoredItems, etag: String?) {
flagCache.saveCachedData(flagStore.storedItems, cacheKey: context.contextHash(), lastUpdated: Date(), etag: etag)
flagChangeNotifier.notifyObservers(oldFlags: oldStoredItems.featureFlags, newFlags: flagStore.storedItems.featureFlags)
}

Expand Down Expand Up @@ -779,14 +780,16 @@ public class LDClient {
NotificationCenter.default.addObserver(self, selector: #selector(didCloseEventSource), name: Notification.Name(FlagSynchronizer.Constants.didCloseEventSourceName), object: nil)

eventReporter = self.serviceFactory.makeEventReporter(service: service, onSyncComplete: onEventSyncComplete)
let cachedData = flagCache.getCachedData(cacheKey: context.contextHash())
service.resetFlagResponseCache(etag: cachedData.etag)
flagSynchronizer = self.serviceFactory.makeFlagSynchronizer(streamingMode: config.allowStreamingMode ? config.streamingMode : .polling,
pollingInterval: config.flagPollingInterval(runMode: runMode),
useReport: config.useReport,
service: service,
onSyncComplete: onFlagSyncComplete)

Log.level = environmentReporter.isDebugBuild && config.isDebugMode ? .debug : .noLogging
if let cachedFlags = flagCache.retrieveFeatureFlags(contextKey: context.fullyQualifiedHashedKey()), !cachedFlags.isEmpty {
if let cachedFlags = cachedData.items, !cachedFlags.isEmpty {
flagStore.replaceStore(newStoredItems: cachedFlags)
}

Expand Down
39 changes: 27 additions & 12 deletions LaunchDarkly/LaunchDarkly/Models/Context/LDContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ public struct LDContext: Encodable, Equatable {
}
}

static private func encodeSingleContext(container: inout KeyedEncodingContainer<DynamicCodingKeys>, context: LDContext, discardKind: Bool, includePrivateAttributes: Bool, allAttributesPrivate: Bool, globalPrivateAttributes: SharedDictionary<String, PrivateAttributeLookupNode>) throws {
static private func encodeSingleContext(container: inout KeyedEncodingContainer<DynamicCodingKeys>, context: LDContext, discardKind: Bool, redactAttributes: Bool, allAttributesPrivate: Bool, globalPrivateAttributes: SharedDictionary<String, PrivateAttributeLookupNode>) throws {
if !discardKind {
try container.encodeIfPresent(context.kind.description, forKey: DynamicCodingKeys(string: "kind"))
}
Expand All @@ -121,7 +121,7 @@ public struct LDContext: Encodable, Equatable {

var path: [String] = []
path.reserveCapacity(10)
try LDContext.writeFilterAttribute(context: context, container: &container, parentPath: path, key: key, value: value, redactedAttributes: &redactedAttributes, includePrivateAttributes: includePrivateAttributes, globalPrivateAttributes: globalPrivateAttributes)
try LDContext.writeFilterAttribute(context: context, container: &container, parentPath: path, key: key, value: value, redactedAttributes: &redactedAttributes, redactAttributes: redactAttributes, globalPrivateAttributes: globalPrivateAttributes)
}
}

Expand All @@ -136,14 +136,14 @@ public struct LDContext: Encodable, Equatable {
}
}

static private func writeFilterAttribute(context: LDContext, container: inout KeyedEncodingContainer<DynamicCodingKeys>, parentPath: [String], key: String, value: LDValue, redactedAttributes: inout [String], includePrivateAttributes: Bool, globalPrivateAttributes: SharedDictionary<String, PrivateAttributeLookupNode>) throws {
static private func writeFilterAttribute(context: LDContext, container: inout KeyedEncodingContainer<DynamicCodingKeys>, parentPath: [String], key: String, value: LDValue, redactedAttributes: inout [String], redactAttributes: Bool, globalPrivateAttributes: SharedDictionary<String, PrivateAttributeLookupNode>) throws {
var path = parentPath
path.append(key.description)

let (isReacted, nestedPropertiesAreRedacted) = includePrivateAttributes ? (false, false) : LDContext.maybeRedact(context: context, parentPath: path, value: value, redactedAttributes: &redactedAttributes, globalPrivateAttributes: globalPrivateAttributes)
let (isRedacted, nestedPropertiesAreRedacted) = !redactAttributes ? (false, false) : LDContext.maybeRedact(context: context, parentPath: path, value: value, redactedAttributes: &redactedAttributes, globalPrivateAttributes: globalPrivateAttributes)

switch value {
case .object where isReacted:
case .object where isRedacted:
break
case .object(let objectMap):
if !nestedPropertiesAreRedacted {
Expand All @@ -154,9 +154,9 @@ public struct LDContext: Encodable, Equatable {
// TODO(mmk): This might be a problem. We might write a sub container even if all the attributes are completely filtered out.
var subContainer = container.nestedContainer(keyedBy: DynamicCodingKeys.self, forKey: DynamicCodingKeys(string: key))
for (key, value) in objectMap {
try writeFilterAttribute(context: context, container: &subContainer, parentPath: path, key: key, value: value, redactedAttributes: &redactedAttributes, includePrivateAttributes: includePrivateAttributes, globalPrivateAttributes: globalPrivateAttributes)
try writeFilterAttribute(context: context, container: &subContainer, parentPath: path, key: key, value: value, redactedAttributes: &redactedAttributes, redactAttributes: redactAttributes, globalPrivateAttributes: globalPrivateAttributes)
}
case _ where !isReacted:
case _ where !isRedacted:
try container.encode(value, forKey: DynamicCodingKeys(string: key))
default:
break
Expand Down Expand Up @@ -230,30 +230,31 @@ public struct LDContext: Encodable, Equatable {

internal struct UserInfoKeys {
static let includePrivateAttributes = CodingUserInfoKey(rawValue: "LD_includePrivateAttributes")!
static let redactAttributes = CodingUserInfoKey(rawValue: "LD_redactAttributes")!
static let allAttributesPrivate = CodingUserInfoKey(rawValue: "LD_allAttributesPrivate")!
static let globalPrivateAttributes = CodingUserInfoKey(rawValue: "LD_globalPrivateAttributes")!
}

public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: DynamicCodingKeys.self)

let includePrivateAttributes = encoder.userInfo[UserInfoKeys.includePrivateAttributes] as? Bool ?? false
let allAttributesPrivate = encoder.userInfo[UserInfoKeys.allAttributesPrivate] as? Bool ?? false
let globalPrivateAttributes = encoder.userInfo[UserInfoKeys.globalPrivateAttributes] as? [Reference] ?? []
let redactAttributes = encoder.userInfo[UserInfoKeys.redactAttributes] as? Bool ?? true

let allPrivate = !includePrivateAttributes && allAttributesPrivate
let globalPrivate = includePrivateAttributes ? [] : globalPrivateAttributes
let allPrivate = redactAttributes && allAttributesPrivate
let globalPrivate = redactAttributes ? globalPrivateAttributes : []
let globalDictionary = LDContext.makePrivateAttributeLookupData(references: globalPrivate)

if isMulti() {
try container.encodeIfPresent(kind.description, forKey: DynamicCodingKeys(string: "kind"))

for context in contexts {
var contextContainer = container.nestedContainer(keyedBy: DynamicCodingKeys.self, forKey: DynamicCodingKeys(string: context.kind.description))
try LDContext.encodeSingleContext(container: &contextContainer, context: context, discardKind: true, includePrivateAttributes: includePrivateAttributes, allAttributesPrivate: allPrivate, globalPrivateAttributes: globalDictionary)
try LDContext.encodeSingleContext(container: &contextContainer, context: context, discardKind: true, redactAttributes: redactAttributes, allAttributesPrivate: allPrivate, globalPrivateAttributes: globalDictionary)
}
} else {
try LDContext.encodeSingleContext(container: &container, context: self, discardKind: false, includePrivateAttributes: includePrivateAttributes, allAttributesPrivate: allPrivate, globalPrivateAttributes: globalDictionary)
try LDContext.encodeSingleContext(container: &container, context: self, discardKind: false, redactAttributes: redactAttributes, allAttributesPrivate: allPrivate, globalPrivateAttributes: globalDictionary)
}
}

Expand Down Expand Up @@ -335,6 +336,20 @@ public struct LDContext: Encodable, Equatable {
return Util.sha256base64(fullyQualifiedKey()) + "$"
}

func contextHash() -> String {
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
encoder.userInfo[UserInfoKeys.redactAttributes] = false

guard let json = try? encoder.encode(self)
else { return fullyQualifiedKey() }

if let jsonStr = String(data: json, encoding: .utf8) {
return Util.sha256base64(jsonStr)
}
return fullyQualifiedKey()
}

/// - Returns: true if the `LDContext` is a multi-context; false otherwise.
public func isMulti() -> Bool {
return self.kind.isMulti()
Expand Down
21 changes: 12 additions & 9 deletions LaunchDarkly/LaunchDarkly/Networking/DarklyService.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Foundation
import LDSwiftEventSource

typealias ServiceResponse = (data: Data?, urlResponse: URLResponse?, error: Error?)
typealias ServiceResponse = (data: Data?, urlResponse: URLResponse?, error: Error?, etag: String?)
typealias ServiceCompletionHandler = (ServiceResponse) -> Void

// sourcery: autoMockable
Expand All @@ -18,7 +18,7 @@ protocol DarklyServiceProvider: AnyObject {
var diagnosticCache: DiagnosticCaching? { get }

func getFeatureFlags(useReport: Bool, completion: ServiceCompletionHandler?)
func clearFlagResponseCache()
func resetFlagResponseCache(etag: String?)
func createEventSource(useReport: Bool, handler: EventHandler, errorHandler: ConnectionErrorHandler?) -> DarklyStreamingProvider
func publishEventData(_ eventData: Data, _ payloadId: String, completion: ServiceCompletionHandler?)
func publishDiagnostic<T: DiagnosticEvent & Encodable>(diagnosticEvent: T, completion: ServiceCompletionHandler?)
Expand Down Expand Up @@ -82,14 +82,15 @@ final class DarklyService: DarklyServiceProvider {

// MARK: Feature Flags

func clearFlagResponseCache() {
flagRequestEtag = nil
func resetFlagResponseCache(etag: String?) {
flagRequestEtag = etag
}

func getFeatureFlags(useReport: Bool, completion: ServiceCompletionHandler?) {
guard hasMobileKey(#function) else { return }
let encoder = JSONEncoder()
encoder.userInfo[LDContext.UserInfoKeys.includePrivateAttributes] = true
encoder.userInfo[LDContext.UserInfoKeys.redactAttributes] = false
encoder.outputFormatting = [.sortedKeys]

guard let contextJsonData = try? encoder.encode(context)
Expand All @@ -112,8 +113,8 @@ final class DarklyService: DarklyServiceProvider {

self.session.dataTask(with: request) { [weak self] data, response, error in
DispatchQueue.main.async {
self?.processEtag(from: (data, response, error))
completion?((data, response, error))
self?.processEtag(from: (data: data, urlResponse: response, error: error, etag: self?.flagRequestEtag))
completion?((data: data, urlResponse: response, error: error, etag: self?.flagRequestEtag))
}
}.resume()
}
Expand All @@ -140,8 +141,8 @@ final class DarklyService: DarklyServiceProvider {

private func processEtag(from serviceResponse: ServiceResponse) {
guard serviceResponse.error == nil,
serviceResponse.urlResponse?.httpStatusCode == HTTPURLResponse.StatusCodes.ok,
serviceResponse.data?.jsonDictionary != nil
serviceResponse.urlResponse?.httpStatusCode == HTTPURLResponse.StatusCodes.ok,
serviceResponse.data?.jsonDictionary != nil
else {
if serviceResponse.urlResponse?.httpStatusCode != HTTPURLResponse.StatusCodes.notModified {
flagRequestEtag = nil
Expand All @@ -158,7 +159,9 @@ final class DarklyService: DarklyServiceProvider {
errorHandler: ConnectionErrorHandler?) -> DarklyStreamingProvider {
let encoder = JSONEncoder()
encoder.userInfo[LDContext.UserInfoKeys.includePrivateAttributes] = true
encoder.userInfo[LDContext.UserInfoKeys.redactAttributes] = false
encoder.outputFormatting = [.sortedKeys]

let contextJsonData = try? encoder.encode(context)

var streamRequestUrl = config.streamUrl.appendingPathComponent(StreamRequestPath.meval)
Expand Down Expand Up @@ -205,7 +208,7 @@ final class DarklyService: DarklyServiceProvider {
request.httpBody = body

session.dataTask(with: request) { data, response, error in
completion?((data, response, error))
completion?((data: data, urlResponse: response, error: error, etag: nil))
}.resume()
}

Expand Down
Loading

0 comments on commit 701aaa8

Please sign in to comment.