Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions ContractTests/Source/Controllers/SdkController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]

Expand Down Expand Up @@ -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)!
Expand Down
2 changes: 2 additions & 0 deletions ContractTests/Source/Models/client.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
28 changes: 28 additions & 0 deletions LaunchDarkly/LaunchDarkly/Models/LDConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
22 changes: 15 additions & 7 deletions LaunchDarkly/LaunchDarkly/Networking/DarklyService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions LaunchDarkly/LaunchDarkly/ObjectiveC/ObjcLDConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
19 changes: 19 additions & 0 deletions LaunchDarkly/LaunchDarklyTests/Models/LDConfigSpec.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 }),
Expand All @@ -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)
Expand Down Expand Up @@ -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)")
Expand Down Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -53,6 +57,7 @@ final class DarklyServiceSpec: QuickSpec {
flagRequestEtagSpec()
clearFlagRequestCacheSpec()
createEventSourceSpec()
payloadFilterSpec()
publishEventDataSpec()
diagnosticCacheSpec()
publishDiagnosticSpec()
Expand Down Expand Up @@ -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!
Expand Down
Loading