From 472c127b74c51d4faff9c37b86ffc1159a3d3276 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Wed, 4 Nov 2026 18:10:12 -0800 Subject: [PATCH] feat: Add payloadFilter option to request a filtered environment Co-authored-by: Cursor --- .../Source/Controllers/SdkController.swift | 4 ++ ContractTests/Source/Models/client.swift | 2 + .../LaunchDarkly/Models/LDConfig.swift | 28 +++++++++ .../Networking/DarklyService.swift | 22 ++++--- .../ObjectiveC/ObjcLDConfig.swift | 6 ++ .../Models/LDConfigSpec.swift | 19 +++++++ .../Networking/DarklyServiceSpec.swift | 57 ++++++++++++++++++- 7 files changed, 130 insertions(+), 8 deletions(-) diff --git a/ContractTests/Source/Controllers/SdkController.swift b/ContractTests/Source/Controllers/SdkController.swift index 8ea4d50cb..ae211372f 100644 --- a/ContractTests/Source/Controllers/SdkController.swift +++ b/ContractTests/Source/Controllers/SdkController.swift @@ -43,6 +43,8 @@ final class SdkController: RouteCollection { "retry-conformance-fdv1-streaming", "retry-conformance-fdv1-polling", "client-event-source-http-errors", + "filtering", + "filtering-strict", "tls:verify-peer" ] @@ -75,6 +77,8 @@ final class SdkController: RouteCollection { } } + config.payloadFilter = createInstance.configuration.streaming?.filter ?? createInstance.configuration.polling?.filter + if let events = createInstance.configuration.events { if let baseUri = events.baseUri { config.eventsUrl = URL(string: baseUri)! diff --git a/ContractTests/Source/Models/client.swift b/ContractTests/Source/Models/client.swift index 92ec009c6..71ca3a085 100644 --- a/ContractTests/Source/Models/client.swift +++ b/ContractTests/Source/Models/client.swift @@ -23,10 +23,12 @@ struct Configuration: Content { struct StreamingParameters: Content { var baseUri: String? var initialRetryDelayMs: Int? + var filter: String? } struct PollingParameters: Content { var baseUri: String? + var filter: String? // TODO(mmk) Add pollIntervalMs } diff --git a/LaunchDarkly/LaunchDarkly/Models/LDConfig.swift b/LaunchDarkly/LaunchDarkly/Models/LDConfig.swift index f4edfa3bf..bee7f689f 100644 --- a/LaunchDarkly/LaunchDarkly/Models/LDConfig.swift +++ b/LaunchDarkly/LaunchDarkly/Models/LDConfig.swift @@ -224,6 +224,9 @@ public struct LDConfig { /// The default setting for whether we request evaluation reasons for all flags. (false) static let evaluationReasons = false + /// The default payload filter key. (nil, the full environment is requested) + static let payloadFilter: String? = nil + /// The default setting for the maximum number of locally cached contexts. (5) static let maxCachedContexts = 5 @@ -392,6 +395,17 @@ public struct LDConfig { /// Enables requesting evaluation reasons for all flags. (Default: false) public var evaluationReasons: Bool = Defaults.evaluationReasons + /** + The key of a payload filter to apply to flag data requests. (Default: nil) + + Payload filters are defined per project in LaunchDarkly. When a filter key is set, the SDK adds it as the `filter` query + parameter to its streaming and polling requests, and LaunchDarkly limits the flags it sends to the ones selected by that filter. + + The key must contain only alphanumeric characters, periods, underscores, and hyphens, and must start with an alphanumeric + character. If the key is invalid, the SDK logs an error and requests the full environment instead. + */ + public var payloadFilter: String? = Defaults.payloadFilter + /// An Integer that tells ContextEnvironmentFlagCache the maximum number of contexts to locally cache. Can be set to -1 for unlimited cached contexts. public var maxCachedContexts: Int = Defaults.maxCachedContexts @@ -532,6 +546,19 @@ public struct LDConfig { return runMode == .foreground ? max(flagPollingInterval, minima.flagPollingInterval) : max(backgroundFlagPollingInterval, minima.backgroundFlagPollingInterval) } + // Returns the payload filter key to send with flag requests, or nil if none is set or the configured key is invalid. + func validatedPayloadFilter() -> String? { + guard let payloadFilter = payloadFilter, !payloadFilter.isEmpty + else { return nil } + + guard payloadFilter.range(of: "\\A[a-zA-Z0-9][._\\-a-zA-Z0-9]*\\z", options: .regularExpression) != nil + else { + os_log("%s Payload filter \"%s\" is invalid; the full environment will be requested instead", log: logger, type: .error, typeName(and: #function), payloadFilter) + return nil + } + return payloadFilter + } + // Determines if the status code is a code that should cause the SDK to retry a failed HTTP Request that used the REPORT method. Retried requests will use the GET method. static func isReportRetryStatusCode(_ statusCode: Int) -> Bool { return LDConfig.flagRetryStatusCodes.contains(statusCode) @@ -560,6 +587,7 @@ extension LDConfig: Equatable { && lhs.useReport == rhs.useReport && lhs.isDebugMode == rhs.isDebugMode && lhs.evaluationReasons == rhs.evaluationReasons + && lhs.payloadFilter == rhs.payloadFilter && lhs.maxCachedContexts == rhs.maxCachedContexts && lhs.diagnosticOptOut == rhs.diagnosticOptOut && lhs.diagnosticRecordingInterval == rhs.diagnosticRecordingInterval diff --git a/LaunchDarkly/LaunchDarkly/Networking/DarklyService.swift b/LaunchDarkly/LaunchDarkly/Networking/DarklyService.swift index 7d980c859..6d3c6cc5a 100644 --- a/LaunchDarkly/LaunchDarkly/Networking/DarklyService.swift +++ b/LaunchDarkly/LaunchDarkly/Networking/DarklyService.swift @@ -53,12 +53,14 @@ final class DarklyService: DarklyServiceProvider { let diagnosticCache: DiagnosticCaching? private(set) var serviceFactory: ClientServiceCreating private var session: URLSession + private let payloadFilter: String? var flagRequestEtag: String? init(config: LDConfig, context: LDContext, envReporter: EnvironmentReporting, serviceFactory: ClientServiceCreating) { self.config = config self.context = context self.serviceFactory = serviceFactory + self.payloadFilter = config.validatedPayloadFilter() if !config.mobileKey.isEmpty && !config.diagnosticOptOut && config.sendEvents { self.diagnosticCache = serviceFactory.makeDiagnosticCache(sdkKey: config.mobileKey) @@ -129,16 +131,22 @@ final class DarklyService: DarklyServiceProvider { } else { flagRequestUrl.appendPathComponent(FlagRequestPath.report, isDirectory: false) } - return shouldGetReasons(url: flagRequestUrl) + return addingQueryItems(to: flagRequestUrl) } - private func shouldGetReasons(url: URL) -> URL { - guard config.evaluationReasons + private func addingQueryItems(to url: URL) -> URL { + var queryItems: [URLQueryItem] = [] + if config.evaluationReasons { + queryItems.append(URLQueryItem(name: "withReasons", value: "true")) + } + if let payloadFilter = payloadFilter { + queryItems.append(URLQueryItem(name: "filter", value: payloadFilter)) + } + guard !queryItems.isEmpty, var urlComponent = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return url } - var urlComponent = URLComponents(url: url, resolvingAgainstBaseURL: false) - urlComponent?.queryItems = [URLQueryItem(name: "withReasons", value: "true")] - return urlComponent?.url ?? url + urlComponent.queryItems = queryItems + return urlComponent.url ?? url } private func processEtag(from serviceResponse: ServiceResponse) { @@ -177,7 +185,7 @@ final class DarklyService: DarklyServiceProvider { streamRequestUrl.appendPathComponent(contextJsonData?.base64UrlEncodedString ?? "", isDirectory: false) } - return serviceFactory.makeStreamingProvider(url: shouldGetReasons(url: streamRequestUrl), + return serviceFactory.makeStreamingProvider(url: addingQueryItems(to: streamRequestUrl), httpHeaders: httpHeaders.eventSourceHeaders, connectMethod: connectMethod, connectBody: connectBody, diff --git a/LaunchDarkly/LaunchDarkly/ObjectiveC/ObjcLDConfig.swift b/LaunchDarkly/LaunchDarkly/ObjectiveC/ObjcLDConfig.swift index 2616c1391..c801c8341 100644 --- a/LaunchDarkly/LaunchDarkly/ObjectiveC/ObjcLDConfig.swift +++ b/LaunchDarkly/LaunchDarkly/ObjectiveC/ObjcLDConfig.swift @@ -166,6 +166,12 @@ public final class ObjcLDConfig: NSObject { set { config.diagnosticRecordingInterval = newValue } } + /// The key of a payload filter to apply to flag data requests. If the key is invalid, the SDK logs an error and requests the full environment instead. (Default: nil) + @objc public var payloadFilter: String? { + get { config.payloadFilter } + set { config.payloadFilter = newValue } + } + /// For use by wrapper libraries to set an identifying name for the wrapper being used. This will be sent in the "X-LaunchDarkly-Wrapper" header on requests to the LaunchDarkly servers to allow recording metrics on the usage of these wrapper libraries. @objc public var wrapperName: String? { get { config.wrapperName } diff --git a/LaunchDarkly/LaunchDarklyTests/Models/LDConfigSpec.swift b/LaunchDarkly/LaunchDarklyTests/Models/LDConfigSpec.swift index f5457044d..a4330678e 100644 --- a/LaunchDarkly/LaunchDarklyTests/Models/LDConfigSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/Models/LDConfigSpec.swift @@ -23,6 +23,7 @@ final class LDConfigSpec: XCTestCase { fileprivate static let debugMode = true fileprivate static let evaluationReasons = true + fileprivate static let payloadFilter = "mobile-flags" fileprivate static let maxCachedContexts = -1 fileprivate static let diagnosticOptOut = true fileprivate static let diagnosticRecordingInterval: TimeInterval = 600.0 @@ -48,6 +49,7 @@ final class LDConfigSpec: XCTestCase { ("private context attributes", Constants.privateContextAttributes, { c, v in c.privateContextAttributes = (v as! [Reference])}), ("use report", Constants.useReport, { c, v in c.useReport = v as! Bool }), ("evaluation reasons", Constants.evaluationReasons, { c, v in c.evaluationReasons = v as! Bool }), + ("payload filter", Constants.payloadFilter, { c, v in c.payloadFilter = v as! String? }), ("max cached contexts", Constants.maxCachedContexts, { c, v in c.maxCachedContexts = v as! Int }), ("diagnostic opt out", Constants.diagnosticOptOut, { c, v in c.diagnosticOptOut = v as! Bool }), ("diagnostic recording interval", Constants.diagnosticRecordingInterval, { c, v in c.diagnosticRecordingInterval = v as! TimeInterval }), @@ -74,6 +76,7 @@ final class LDConfigSpec: XCTestCase { XCTAssertEqual(config.useReport, LDConfig.Defaults.useReport) XCTAssertEqual(config.isDebugMode, LDConfig.Defaults.debugMode) XCTAssertEqual(config.evaluationReasons, LDConfig.Defaults.evaluationReasons) + XCTAssertEqual(config.payloadFilter, LDConfig.Defaults.payloadFilter) XCTAssertEqual(config.maxCachedContexts, LDConfig.Defaults.maxCachedContexts) XCTAssertEqual(config.diagnosticOptOut, LDConfig.Defaults.diagnosticOptOut) XCTAssertEqual(config.diagnosticRecordingInterval, LDConfig.Defaults.diagnosticRecordingInterval) @@ -108,6 +111,7 @@ final class LDConfigSpec: XCTestCase { XCTAssertEqual(config.useReport, Constants.useReport, "\(os)") XCTAssertEqual(config.isDebugMode, Constants.debugMode, "\(os)") XCTAssertEqual(config.evaluationReasons, Constants.evaluationReasons, "\(os)") + XCTAssertEqual(config.payloadFilter, Constants.payloadFilter, "\(os)") XCTAssertEqual(config.maxCachedContexts, Constants.maxCachedContexts, "\(os)") XCTAssertEqual(config.diagnosticOptOut, Constants.diagnosticOptOut, "\(os)") XCTAssertEqual(config.diagnosticRecordingInterval, Constants.diagnosticRecordingInterval, "\(os)") @@ -187,6 +191,21 @@ final class LDConfigSpec: XCTestCase { } } + func testValidatedPayloadFilter() { + var config = LDConfig(mobileKey: LDConfig.Constants.mockMobileKey, autoEnvAttributes: .disabled) + XCTAssertNil(config.validatedPayloadFilter()) + + ["mobile-flags", "a", "0.filter_key-1"].forEach { key in + config.payloadFilter = key + XCTAssertEqual(config.validatedPayloadFilter(), key) + } + + ["", "-starts-with-hyphen", ".starts-with-period", "has space", "encoding necessary +! %& ( )", "trailing-newline\n", "ключ"].forEach { key in + config.payloadFilter = key + XCTAssertNil(config.validatedPayloadFilter(), "\(key) should be rejected") + } + } + func testIsReportRetryStatusCode() { HTTPURLResponse.StatusCodes.all.forEach { statusCode in XCTAssertEqual(LDConfig.isReportRetryStatusCode(statusCode), diff --git a/LaunchDarkly/LaunchDarklyTests/Networking/DarklyServiceSpec.swift b/LaunchDarkly/LaunchDarklyTests/Networking/DarklyServiceSpec.swift index 19a917e48..63e860e37 100644 --- a/LaunchDarkly/LaunchDarklyTests/Networking/DarklyServiceSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/Networking/DarklyServiceSpec.swift @@ -27,11 +27,15 @@ final class DarklyServiceSpec: QuickSpec { init(mobileKey: String = LDConfig.Constants.mockMobileKey, useReport: Bool = Constants.useGetMethod, - diagnosticOptOut: Bool = false) { + diagnosticOptOut: Bool = false, + evaluationReasons: Bool = false, + payloadFilter: String? = nil) { config = LDConfig.stub(mobileKey: mobileKey, autoEnvAttributes: .disabled, isDebugBuild: true) config.useReport = useReport config.diagnosticOptOut = diagnosticOptOut + config.evaluationReasons = evaluationReasons + config.payloadFilter = payloadFilter self.serviceFactoryMock = ClientServiceMockFactory(config: config) serviceMock = DarklyServiceMock(config: config) service = DarklyService(config: config, context: context, envReporter: envReporterMock, serviceFactory: serviceFactoryMock) @@ -53,6 +57,7 @@ final class DarklyServiceSpec: QuickSpec { flagRequestEtagSpec() clearFlagRequestCacheSpec() createEventSourceSpec() + payloadFilterSpec() publishEventDataSpec() diagnosticCacheSpec() publishDiagnosticSpec() @@ -568,6 +573,56 @@ final class DarklyServiceSpec: QuickSpec { } } + private func payloadFilterSpec() { + func streamUrl(_ testContext: TestContext, useReport: Bool) -> URL? { + _ = testContext.service.createEventSource(useReport: useReport, handler: EventHandlerMock(), errorHandler: nil) + return testContext.serviceFactoryMock.makeStreamingProviderReceivedArguments?.url + } + + func pollUrl(_ testContext: TestContext, useReport: Bool) -> URL? { + var urlRequest: URLRequest? + waitUntil(timeout: .seconds(5)) { done in + testContext.serviceMock.stubFlagRequest(statusCode: HTTPURLResponse.StatusCodes.ok, + featureFlags: testContext.stubFlags.featureFlags, + useReport: useReport, + onActivation: { request in urlRequest = request }) + testContext.service.getFeatureFlags(useReport: useReport, completion: { _ in done() }) + } + return urlRequest?.url + } + + describe("payload filter") { + [Constants.useGetMethod, Constants.useReportMethod].forEach { useReport in + let method = useReport ? "REPORT" : "GET" + context("using \(method) method") { + it("adds the filter to the stream url") { + let testContext = TestContext(useReport: useReport, payloadFilter: "mobile-flags") + expect(streamUrl(testContext, useReport: useReport)?.query) == "filter=mobile-flags" + } + it("adds the filter to the poll url") { + let testContext = TestContext(useReport: useReport, payloadFilter: "mobile-flags") + expect(pollUrl(testContext, useReport: useReport)?.query) == "filter=mobile-flags" + } + it("keeps evaluation reasons alongside the filter") { + let testContext = TestContext(useReport: useReport, evaluationReasons: true, payloadFilter: "mobile-flags") + expect(streamUrl(testContext, useReport: useReport)?.query) == "withReasons=true&filter=mobile-flags" + expect(pollUrl(testContext, useReport: useReport)?.query) == "withReasons=true&filter=mobile-flags" + } + it("omits an invalid filter") { + let testContext = TestContext(useReport: useReport, payloadFilter: "not valid") + expect(streamUrl(testContext, useReport: useReport)?.query).to(beNil()) + expect(pollUrl(testContext, useReport: useReport)?.query).to(beNil()) + } + it("omits the query when no filter is set") { + let testContext = TestContext(useReport: useReport) + expect(streamUrl(testContext, useReport: useReport)?.query).to(beNil()) + expect(pollUrl(testContext, useReport: useReport)?.query).to(beNil()) + } + } + } + } + } + private func publishEventDataSpec() { let testData = Data("abc".utf8) var testContext: TestContext!