Skip to content

Commit

Permalink
chore: Add hook support to contract tests (#371)
Browse files Browse the repository at this point in the history
  • Loading branch information
keelerm84 committed May 2, 2024
1 parent e7cebc1 commit 69b6067
Show file tree
Hide file tree
Showing 12 changed files with 185 additions and 7 deletions.
6 changes: 6 additions & 0 deletions .github/actions/ci/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
1 change: 1 addition & 0 deletions .github/workflows/manual-publish-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions .github/workflows/manual-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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' }}
Expand Down
15 changes: 14 additions & 1 deletion ContractTests/Source/Controllers/SdkController.swift
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -26,14 +28,16 @@ final class SdkController: RouteCollection {
"context-comparison",
"etag-caching",
"inline-context",
"anonymous-redaction"
"anonymous-redaction",
"evaluation-hooks"
]

return StatusResponse(
name: "ios-swift-client-sdk",
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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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) }
Expand Down
12 changes: 12 additions & 0 deletions ContractTests/Source/Models/client.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ struct Configuration: Content {
var events: EventParameters?
var tags: TagParameters?
var clientSide: ClientSideParameters
var hooks: HookParameters?
}

struct StreamingParameters: Content {
Expand Down Expand Up @@ -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]?
}
134 changes: 134 additions & 0 deletions ContractTests/Source/Models/hook.swift
Original file line number Diff line number Diff line change
@@ -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<LDValue>) -> EvaluationSeriesData {
return processHook(seriesContext: seriesContext, seriesData: seriesData, evaluationDetail: evaluationDetail, stage: "afterEvaluation")
}

private func processHook(seriesContext: EvaluationSeriesContext, seriesData: EvaluationSeriesData, evaluationDetail: LDEvaluationDetail<LDValue>?, 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<LDValue>?

init(evaluationSeriesContext: EvaluationSeriesContext, evaluationSeriesData: EvaluationSeriesData, stage: String, evaluationDetail: LDEvaluationDetail<LDValue>? = 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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion LaunchDarkly/LaunchDarkly/Models/Hooks/Hook.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion LaunchDarkly/LaunchDarkly/Models/Hooks/Metadata.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down

0 comments on commit 69b6067

Please sign in to comment.