From 69b6067f40b8a9a2cddad64f939b238ded2989e6 Mon Sep 17 00:00:00 2001 From: "Matthew M. Keeler" Date: Thu, 2 May 2024 11:22:45 -0400 Subject: [PATCH] chore: Add hook support to contract tests (#371) --- .github/actions/ci/action.yml | 6 + .github/workflows/ci.yml | 4 + .github/workflows/manual-publish-docs.yml | 1 + .github/workflows/manual-publish.yml | 1 + .github/workflows/release-please.yml | 1 + .../Source/Controllers/SdkController.swift | 15 +- ContractTests/Source/Models/client.swift | 12 ++ ContractTests/Source/Models/hook.swift | 134 ++++++++++++++++++ .../Hooks/EvaluationSeriesContext.swift | 12 +- .../LaunchDarkly/Models/Hooks/Hook.swift | 2 +- .../LaunchDarkly/Models/Hooks/Metadata.swift | 3 +- .../ServiceObjects/FlagSynchronizer.swift | 1 + 12 files changed, 185 insertions(+), 7 deletions(-) create mode 100644 ContractTests/Source/Models/hook.swift diff --git a/.github/actions/ci/action.yml b/.github/actions/ci/action.yml index bf1d4aab..2ef3f34b 100644 --- a/.github/actions/ci/action.yml +++ b/.github/actions/ci/action.yml @@ -10,6 +10,9 @@ inputs: ios-sim: description: 'iOS Simulator to use for testing' required: true + run-contract-tests: + description: 'Should the contract tests be run?' + required: true runs: using: composite @@ -71,14 +74,17 @@ runs: run: swift test -v - name: Build contract tests + if: ${{ inputs.run-contract-tests == 'true' }} shell: bash run: make build-contract-tests - name: Start contract tests in background + if: ${{ inputs.run-contract-tests == 'true' }} shell: bash run: make start-contract-test-service-bg - name: Run contract tests + if: ${{ inputs.run-contract-tests == 'true' }} shell: bash # Add a brief sleep here to ensure the test service is ready to receive # requests diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d7c70835..2a9997ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,12 +20,15 @@ jobs: - xcode-version: 15.0.1 ios-sim: 'platform=iOS Simulator,name=iPhone 15,OS=17.2' os: macos-13 + run-contract-tests: true - xcode-version: 14.3.1 ios-sim: 'platform=iOS Simulator,name=iPhone 14,OS=16.4' os: macos-13 + run-contract-tests: true - xcode-version: 13.4.1 ios-sim: 'platform=iOS Simulator,name=iPhone 11,OS=15.5' os: macos-12 + run-contract-tests: false steps: - uses: actions/checkout@v4 @@ -36,5 +39,6 @@ jobs: with: xcode-version: ${{ matrix.xcode-version }} ios-sim: ${{ matrix.ios-sim }} + run-contract-tests: ${{ matrix.run-contract-tests }} - uses: ./.github/actions/build-docs diff --git a/.github/workflows/manual-publish-docs.yml b/.github/workflows/manual-publish-docs.yml index 2d21f3a3..ee8ebb20 100644 --- a/.github/workflows/manual-publish-docs.yml +++ b/.github/workflows/manual-publish-docs.yml @@ -18,6 +18,7 @@ jobs: with: xcode-version: 14.3.1 ios-sim: 'platform=iOS Simulator,name=iPhone 14,OS=16.4' + run-contract-tests: true - uses: ./.github/actions/build-docs diff --git a/.github/workflows/manual-publish.yml b/.github/workflows/manual-publish.yml index b0aad877..4ee5e918 100644 --- a/.github/workflows/manual-publish.yml +++ b/.github/workflows/manual-publish.yml @@ -29,6 +29,7 @@ jobs: with: xcode-version: 14.3.1 ios-sim: 'platform=iOS Simulator,name=iPhone 14,OS=16.4' + run-contract-tests: true - uses: ./.github/actions/publish with: diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index f3219391..f390a7b6 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -48,6 +48,7 @@ jobs: with: xcode-version: 14.3.1 ios-sim: 'platform=iOS Simulator,name=iPhone 14,OS=16.4' + run-contract-tests: true - uses: ./.github/actions/build-docs if: ${{ steps.release.outputs.releases_created == 'true' }} diff --git a/ContractTests/Source/Controllers/SdkController.swift b/ContractTests/Source/Controllers/SdkController.swift index cd7ca1ae..c7da2797 100644 --- a/ContractTests/Source/Controllers/SdkController.swift +++ b/ContractTests/Source/Controllers/SdkController.swift @@ -1,6 +1,8 @@ import Vapor +import Foundation @testable import LaunchDarkly +// swiftlint:disable:next type_body_length final class SdkController: RouteCollection { private var clients: [Int: LDClient] = [:] private var clientCounter = 0 @@ -26,7 +28,8 @@ final class SdkController: RouteCollection { "context-comparison", "etag-caching", "inline-context", - "anonymous-redaction" + "anonymous-redaction", + "evaluation-hooks" ] return StatusResponse( @@ -34,6 +37,7 @@ final class SdkController: RouteCollection { capabilities: capabilities) } + // swiftlint:disable:next function_body_length func createClient(_ req: Request) throws -> Response { let createInstance = try req.content.decode(CreateInstance.self) let mobileKey = createInstance.configuration.credential @@ -104,6 +108,14 @@ final class SdkController: RouteCollection { config.applicationInfo = applicationInfo } + if let hooksConfig = createInstance.configuration.hooks { + let hooks: [Hook] = hooksConfig.hooks.map { hookParameter in + let url = URL(string: hookParameter.callbackUri)! + return TestHook(name: hookParameter.name, callbackUrl: url, data: hookParameter.data ?? [:], errors: hookParameter.errors ?? [:]) + } + config.hooks = hooks + } + let clientSide = createInstance.configuration.clientSide if let evaluationReasons = clientSide.evaluationReasons { @@ -151,6 +163,7 @@ final class SdkController: RouteCollection { return HTTPStatus.accepted } + // swiftlint:disable:next function_body_length func executeCommand(_ req: Request) throws -> CommandResponse { guard let id = req.parameters.get("id", as: Int.self) else { throw Abort(.badRequest) } diff --git a/ContractTests/Source/Models/client.swift b/ContractTests/Source/Models/client.swift index 4dedb957..c7582863 100644 --- a/ContractTests/Source/Models/client.swift +++ b/ContractTests/Source/Models/client.swift @@ -16,6 +16,7 @@ struct Configuration: Content { var events: EventParameters? var tags: TagParameters? var clientSide: ClientSideParameters + var hooks: HookParameters? } struct StreamingParameters: Content { @@ -50,3 +51,14 @@ struct ClientSideParameters: Content { var useReport: Bool? var includeEnvironmentAttributes: Bool? } + +struct HookParameters: Content { + var hooks: [HookParameter] +} + +struct HookParameter: Content { + var name: String + var callbackUri: String + var data: [String: [String: LDValue]]? + var errors: [String: LDValue]? +} diff --git a/ContractTests/Source/Models/hook.swift b/ContractTests/Source/Models/hook.swift new file mode 100644 index 00000000..37222b98 --- /dev/null +++ b/ContractTests/Source/Models/hook.swift @@ -0,0 +1,134 @@ +import Foundation +import LaunchDarkly + +class TestHook: Hook { + private let name: String + private let callbackUrl: URL + private let data: [String: [String: Encodable]] + private let errors: [String: LDValue] + + init(name: String, callbackUrl: URL, data: [String: [String: Encodable]], errors: [String: LDValue]) { + self.name = name + self.callbackUrl = callbackUrl + self.data = data + self.errors = errors + } + + func metadata() -> Metadata { + return Metadata(name: self.name) + } + + func beforeEvaluation(seriesContext: EvaluationSeriesContext, seriesData: EvaluationSeriesData) -> LaunchDarkly.EvaluationSeriesData { + return processHook(seriesContext: seriesContext, seriesData: seriesData, evaluationDetail: nil, stage: "beforeEvaluation") + } + + func afterEvaluation(seriesContext: EvaluationSeriesContext, seriesData: EvaluationSeriesData, evaluationDetail: LDEvaluationDetail) -> EvaluationSeriesData { + return processHook(seriesContext: seriesContext, seriesData: seriesData, evaluationDetail: evaluationDetail, stage: "afterEvaluation") + } + + private func processHook(seriesContext: EvaluationSeriesContext, seriesData: EvaluationSeriesData, evaluationDetail: LDEvaluationDetail?, stage: String) -> EvaluationSeriesData { + guard self.errors[stage] == nil else { return seriesData } + + let payload = EvaluationPayload(evaluationSeriesContext: seriesContext, evaluationSeriesData: seriesData, stage: stage, evaluationDetail: evaluationDetail) + + // swiftlint:disable:next force_try + let data = try! JSONEncoder().encode(payload) + + var request = URLRequest(url: self.callbackUrl) + request.httpMethod = "POST" + request.httpBody = data + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + URLSession.shared.dataTask(with: request) { (_, _, _) in + }.resume() + + var updatedData = seriesData + if let be = self.data[stage] { + be.forEach { (key, value) in + updatedData[key] = value + } + } + + return updatedData + } +} + +struct EvaluationPayload: Encodable { + var evaluationSeriesContext: EvaluationSeriesContext + var evaluationSeriesData: EvaluationSeriesData + var stage: String + var evaluationDetail: LDEvaluationDetail? + + init(evaluationSeriesContext: EvaluationSeriesContext, evaluationSeriesData: EvaluationSeriesData, stage: String, evaluationDetail: LDEvaluationDetail? = nil) { + self.evaluationSeriesContext = evaluationSeriesContext + self.evaluationSeriesData = evaluationSeriesData + self.stage = stage + self.evaluationDetail = evaluationDetail + } + + private enum CodingKeys: String, CodingKey { + case evaluationSeriesContext + case evaluationSeriesData + case stage + case evaluationDetail + } + + struct DynamicKey: CodingKey { + let intValue: Int? = nil + let stringValue: String + + init?(intValue: Int) { + return nil + } + + init?(stringValue: String) { + self.stringValue = stringValue + } + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(evaluationSeriesContext, forKey: .evaluationSeriesContext) + try container.encode(stage, forKey: .stage) + + try container.encodeIfPresent(evaluationDetail, forKey: .evaluationDetail) + + var nested = container.nestedContainer(keyedBy: DynamicKey.self, forKey: .evaluationSeriesData) + try evaluationSeriesData.forEach { (_, _) in + try evaluationSeriesData.forEach { try nested.encode($1, forKey: DynamicKey(stringValue: $0)!) } + } + } +} + +extension EvaluationSeriesContext: Encodable { + private enum CodingKeys: String, CodingKey { + case flagKey + case context + case defaultValue + case method + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(flagKey, forKey: .flagKey) + try container.encode(context, forKey: .context) + try container.encode(defaultValue, forKey: .defaultValue) + try container.encode(methodName, forKey: .method) + } +} + +extension LDEvaluationDetail: Encodable where T == LDValue { + private enum CodingKeys: String, CodingKey { + case value + case variationIndex + case reason + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(value, forKey: .value) + try container.encode(variationIndex, forKey: .variationIndex) + try container.encode(reason, forKey: .reason) + } +} diff --git a/LaunchDarkly/LaunchDarkly/Models/Hooks/EvaluationSeriesContext.swift b/LaunchDarkly/LaunchDarkly/Models/Hooks/EvaluationSeriesContext.swift index a375e37e..77fb4ddf 100644 --- a/LaunchDarkly/LaunchDarkly/Models/Hooks/EvaluationSeriesContext.swift +++ b/LaunchDarkly/LaunchDarkly/Models/Hooks/EvaluationSeriesContext.swift @@ -2,10 +2,14 @@ import Foundation /// Contextual information that will be provided to handlers during evaluation series. public class EvaluationSeriesContext { - private let flagKey: String - private let context: LDContext - private let defaultValue: LDValue - private let methodName: String + /// The key of the flag being evaluated. + public let flagKey: String + /// The context in effect at the time of evaluation. + public let context: LDContext + /// The default value provided to the calling evaluation method. + public let defaultValue: LDValue + /// A string identifing the name of the method called. + public let methodName: String init(flagKey: String, context: LDContext, defaultValue: LDValue, methodName: String) { self.flagKey = flagKey diff --git a/LaunchDarkly/LaunchDarkly/Models/Hooks/Hook.swift b/LaunchDarkly/LaunchDarkly/Models/Hooks/Hook.swift index d2a75ebc..a76c2451 100644 --- a/LaunchDarkly/LaunchDarkly/Models/Hooks/Hook.swift +++ b/LaunchDarkly/LaunchDarkly/Models/Hooks/Hook.swift @@ -3,7 +3,7 @@ import Foundation /// Implementation specific hook data for evaluation stages. /// /// Hook implementations can use this to store data needed between stages. -public typealias EvaluationSeriesData = [String: Any] +public typealias EvaluationSeriesData = [String: Encodable] /// Protocol for extending SDK functionality via hooks. public protocol Hook { diff --git a/LaunchDarkly/LaunchDarkly/Models/Hooks/Metadata.swift b/LaunchDarkly/LaunchDarkly/Models/Hooks/Metadata.swift index 323d3e20..ed8d3bb7 100644 --- a/LaunchDarkly/LaunchDarkly/Models/Hooks/Metadata.swift +++ b/LaunchDarkly/LaunchDarkly/Models/Hooks/Metadata.swift @@ -4,7 +4,8 @@ import Foundation public class Metadata { private let name: String - init(name: String) { + /// Initialize a new Metadata instance with the provided name. + public init(name: String) { self.name = name } } diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/FlagSynchronizer.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/FlagSynchronizer.swift index 7bbd5153..e25dcb1d 100644 --- a/LaunchDarkly/LaunchDarkly/ServiceObjects/FlagSynchronizer.swift +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/FlagSynchronizer.swift @@ -334,6 +334,7 @@ class FlagSynchronizer: LDFlagSynchronizing, EventHandler { reportDataError(messageEvent.data.data(using: .utf8)) return } + // NOTE: If you are adding e-tag support through the streaming // connection, make sure you read the documentation on the // FeatureFlagCaching.saveCachedData method.