diff --git a/ConfidenceDemoApp/ConfidenceDemoApp/ConfidenceDemoApp.swift b/ConfidenceDemoApp/ConfidenceDemoApp/ConfidenceDemoApp.swift index 07526a85..9e220881 100644 --- a/ConfidenceDemoApp/ConfidenceDemoApp/ConfidenceDemoApp.swift +++ b/ConfidenceDemoApp/ConfidenceDemoApp/ConfidenceDemoApp.swift @@ -28,6 +28,7 @@ extension ConfidenceDemoApp { } let confidence = Confidence.Builder(clientSecret: secret) + .withRegion(region: .europe) .withInitializationstrategy(initializationStrategy: initializationStrategy) .build() let provider = ConfidenceFeatureProvider(confidence: confidence) @@ -39,8 +40,8 @@ extension ConfidenceDemoApp { Task { await OpenFeatureAPI.shared.setProviderAndWait(provider: provider, initialContext: ctx) confidence.send( - definition: "my_event", - payload: ["my_string_field": ConfidenceValue(string: "hello_from_world")]) + definition: "abcd", + payload: ["my_key": ConfidenceValue(string: "hello_from_world")]) } } } diff --git a/Sources/Confidence/Confidence.swift b/Sources/Confidence/Confidence.swift index e0006979..e75b9bf1 100644 --- a/Sources/Confidence/Confidence.swift +++ b/Sources/Confidence/Confidence.swift @@ -8,6 +8,7 @@ public class Confidence: ConfidenceEventSender { public var region: ConfidenceRegion public var initializationStrategy: InitializationStrategy private var removedContextKeys: Set = Set() + private var client: ConfidenceClient required public init( clientSecret: String, @@ -15,6 +16,7 @@ public class Confidence: ConfidenceEventSender { region: ConfidenceRegion, initializationStrategy: InitializationStrategy, context: ConfidenceStruct = [:], + client: ConfidenceClient, parent: ConfidenceEventSender? = nil ) { self.clientSecret = clientSecret @@ -22,12 +24,16 @@ public class Confidence: ConfidenceEventSender { self.region = region self.initializationStrategy = initializationStrategy self.context = context + self.client = client self.parent = parent } // TODO: Implement actual event uploading to the backend public func send(definition: String, payload: ConfidenceStruct) { print("Sending: \"\(definition)\".\nMessage: \(payload)\nContext: \(context)") + Task { + try? await client.send(definition: definition, payload: payload) + } } @@ -58,6 +64,7 @@ public class Confidence: ConfidenceEventSender { region: region, initializationStrategy: initializationStrategy, context: context, + client: client, parent: self) } } @@ -94,7 +101,13 @@ extension Confidence { clientSecret: clientSecret, timeout: timeout, region: region, - initializationStrategy: initializationStrategy + initializationStrategy: initializationStrategy, + client: RemoteConfidenceClient( + options: ConfidenceClientOptions(credentials: ConfidenceClientCredentials.clientSecret(secret: clientSecret), region: region), + metadata: ConfidenceMetadata( + name: "SDK_ID_SWIFT_CONFIDENCE", + version: "0.1.4") // x-release-please-version + ) ) } } diff --git a/Sources/Confidence/ConfidenceClient/ConfidenceClient.swift b/Sources/Confidence/ConfidenceClient/ConfidenceClient.swift new file mode 100644 index 00000000..f1abb42b --- /dev/null +++ b/Sources/Confidence/ConfidenceClient/ConfidenceClient.swift @@ -0,0 +1,6 @@ +import Foundation + +public protocol ConfidenceClient { + func send(definition: String, payload: ConfidenceStruct) async throws +} + diff --git a/Sources/Confidence/ConfidenceClient/ConfidenceClientOptions.swift b/Sources/Confidence/ConfidenceClient/ConfidenceClientOptions.swift new file mode 100644 index 00000000..498148c1 --- /dev/null +++ b/Sources/Confidence/ConfidenceClient/ConfidenceClientOptions.swift @@ -0,0 +1,28 @@ +import Foundation + +public struct ConfidenceClientOptions { + public var credentials: ConfidenceClientCredentials + public var timeout: TimeInterval + public var region: ConfidenceRegion + + public init( + credentials: ConfidenceClientCredentials, + timeout: TimeInterval? = nil, + region: ConfidenceRegion? = nil + ) { + self.credentials = credentials + self.timeout = timeout ?? 10.0 + self.region = region ?? .global + } +} + +public enum ConfidenceClientCredentials { + case clientSecret(secret: String) + + public func getSecret() -> String { + switch self { + case .clientSecret(let secret): + return secret + } + } +} diff --git a/Sources/Confidence/ConfidenceClient/RemoteConfidenceClient.swift b/Sources/Confidence/ConfidenceClient/RemoteConfidenceClient.swift new file mode 100644 index 00000000..6d6e841f --- /dev/null +++ b/Sources/Confidence/ConfidenceClient/RemoteConfidenceClient.swift @@ -0,0 +1,68 @@ +import Foundation + +public class RemoteConfidenceClient: ConfidenceClient { + private var options: ConfidenceClientOptions + private let metadata: ConfidenceMetadata + private var httpClient: HttpClient + + init( + options: ConfidenceClientOptions, + session: URLSession? = nil, + metadata: ConfidenceMetadata + ) { + self.options = options + self.httpClient = NetworkClient(session: session, region: options.region) + self.metadata = metadata + } + + public func send(definition: String, payload: ConfidenceStruct) async throws { + let request = PublishEventRequest( + eventDefinition: definition, + payload: payload, + clientSecret: options.credentials.getSecret(), + sdk: Sdk(id: metadata.name, version: metadata.version) + ) + + do { + let result: HttpClientResult = + try await self.httpClient.post(path: ":publish", data: request) + switch result { + case .success(let successData): + guard successData.response.status == .ok else { + throw successData.response.mapStatusToError(error: successData.decodedError) + } + return + case .failure(let errorData): + throw handleError(error: errorData) + } + } + } + + private func handleError(error: Error) -> Error { + if error is ConfidenceError { + return error + } else { + return ConfidenceError.grpcError(message: "\(error)") + } + } +} + +struct PublishEventRequest: Encodable { + var eventDefinition: String + var payload: ConfidenceStruct + var clientSecret: String + var sdk: Sdk +} + +struct PublishEventResponse: Codable { +} + +struct Sdk: Encodable { + init(id: String?, version: String?) { + self.id = id ?? "SDK_ID_SWIFT_PROVIDER" + self.version = version ?? "unknown" + } + + var id: String + var version: String +} diff --git a/Sources/Confidence/ConfidenceMetadata.swift b/Sources/Confidence/ConfidenceMetadata.swift new file mode 100644 index 00000000..6e492544 --- /dev/null +++ b/Sources/Confidence/ConfidenceMetadata.swift @@ -0,0 +1,6 @@ +import Foundation + +public struct ConfidenceMetadata { + public var name: String? = "SDK_ID_SWIFT_PROVIDER" + public var version: String? +} diff --git a/Sources/Confidence/Http/HttpClient.swift b/Sources/Confidence/Http/HttpClient.swift new file mode 100644 index 00000000..590812f0 --- /dev/null +++ b/Sources/Confidence/Http/HttpClient.swift @@ -0,0 +1,40 @@ +import Foundation + +typealias HttpClientResult = Result, Error> + +protocol HttpClient { + func post(path: String, data: Encodable) async throws -> HttpClientResult +} + +struct HttpClientResponse { + var decodedData: T? + var decodedError: HttpError? + var response: HTTPURLResponse +} + +struct HttpError: Codable { + var code: Int + var message: String + var details: [String] +} + +enum HttpClientError: Error { + case invalidResponse + case internalError +} + +extension HTTPURLResponse { + func mapStatusToError(error: HttpError?, flag: String = "unknown") -> Error { + let defaultError = ConfidenceError.internalError( + message: "General error: \(error?.message ?? "Unknown error")") + + switch self.status { + case .notFound: + return ConfidenceError.badRequest(message: flag) // TODO + case .badRequest: + return ConfidenceError.badRequest(message: error?.message ?? "") + default: + return defaultError + } + } +} diff --git a/Sources/Confidence/Http/HttpStatusCode.swift b/Sources/Confidence/Http/HttpStatusCode.swift new file mode 100644 index 00000000..0510fdaa --- /dev/null +++ b/Sources/Confidence/Http/HttpStatusCode.swift @@ -0,0 +1,277 @@ +import Foundation + +/// This is a list of Hypertext Transfer Protocol (HTTP) response status codes. +/// It includes codes from IETF internet standards, other IETF RFCs, other specifications, and some additional commonly used codes. +/// The first digit of the status code specifies one of five classes of response; an HTTP client must recognise these five classes at a minimum. +enum HTTPStatusCode: Int, Error { + /// The response class representation of status codes, these get grouped by their first digit. + enum ResponseType { + /// - informational: This class of status code indicates a provisional response, consisting only of the Status-Line and optional headers, and is terminated by an empty line. + case informational + + /// - success: This class of status codes indicates the action requested by the client was received, understood, accepted, and processed successfully. + case success + + /// - redirection: This class of status code indicates the client must take additional action to complete the request. + case redirection + + /// - clientError: This class of status code is intended for situations in which the client seems to have erred. + case clientError + + /// - serverError: This class of status code indicates the server failed to fulfill an apparently valid request. + case serverError + + /// - undefined: The class of the status code cannot be resolved. + case undefined + } + + // + // Informational - 1xx + // + + /// - continue: The server has received the request headers and the client should proceed to send the request body. + case `continue` = 100 + + /// - switchingProtocols: The requester has asked the server to switch protocols and the server has agreed to do so. + case switchingProtocols = 101 + + /// - processing: This code indicates that the server has received and is processing the request, but no response is available yet. + case processing = 102 + + // + // Success - 2xx + // + + /// - ok: Standard response for successful HTTP requests. + /// + // swiftlint:disable identifier_name + case ok = 200 + // swiftlint:enable identifier_name + + /// - created: The request has been fulfilled, resulting in the creation of a new resource. + case created = 201 + + /// - accepted: The request has been accepted for processing, but the processing has not been completed. + case accepted = 202 + + /// - nonAuthoritativeInformation: The server is a transforming proxy (e.g. a Web accelerator) that received a 200 OK from its origin, but is returning a modified version of the origin's response. + case nonAuthoritativeInformation = 203 + + /// - noContent: The server successfully processed the request and is not returning any content. + case noContent = 204 + + /// - resetContent: The server successfully processed the request, but is not returning any content. + case resetContent = 205 + + /// - partialContent: The server is delivering only part of the resource (byte serving) due to a range header sent by the client. + case partialContent = 206 + + /// - multiStatus: The message body that follows is an XML message and can contain a number of separate response codes, depending on how many sub-requests were made. + case multiStatus = 207 + + /// - alreadyReported: The members of a DAV binding have already been enumerated in a previous reply to this request, and are not being included again. + case alreadyReported = 208 + + /// - IMUsed: The server has fulfilled a request for the resource, and the response is a representation of the result of one or more instance-manipulations applied to the current instance. + case IMUsed = 226 + + // + // Redirection - 3xx + // + + /// - multipleChoices: Indicates multiple options for the resource from which the client may choose + case multipleChoices = 300 + + /// - movedPermanently: This and all future requests should be directed to the given URI. + case movedPermanently = 301 + + /// - found: The resource was found. + case found = 302 + + /// - seeOther: The response to the request can be found under another URI using a GET method. + case seeOther = 303 + + /// - notModified: Indicates that the resource has not been modified since the version specified by the request headers If-Modified-Since or If-None-Match. + case notModified = 304 + + /// - useProxy: The requested resource is available only through a proxy, the address for which is provided in the response. + case useProxy = 305 + + /// - switchProxy: No longer used. Originally meant "Subsequent requests should use the specified proxy. + case switchProxy = 306 + + /// - temporaryRedirect: The request should be repeated with another URI. + case temporaryRedirect = 307 + + /// - permenantRedirect: The request and all future requests should be repeated using another URI. + case permenantRedirect = 308 + + // + // Client Error - 4xx + // + + /// - badRequest: The server cannot or will not process the request due to an apparent client error. + case badRequest = 400 + + /// - unauthorized: Similar to 403 Forbidden, but specifically for use when authentication is required and has failed or has not yet been provided. + case unauthorized = 401 + + /// - paymentRequired: The content available on the server requires payment. + case paymentRequired = 402 + + /// - forbidden: The request was a valid request, but the server is refusing to respond to it. + case forbidden = 403 + + /// - notFound: The requested resource could not be found but may be available in the future. + case notFound = 404 + + /// - methodNotAllowed: A request method is not supported for the requested resource. e.g. a GET request on a form which requires data to be presented via POST + case methodNotAllowed = 405 + + /// - notAcceptable: The requested resource is capable of generating only content not acceptable according to the Accept headers sent in the request. + case notAcceptable = 406 + + /// - proxyAuthenticationRequired: The client must first authenticate itself with the proxy. + case proxyAuthenticationRequired = 407 + + /// - requestTimeout: The server timed out waiting for the request. + case requestTimeout = 408 + + /// - conflict: Indicates that the request could not be processed because of conflict in the request, such as an edit conflict between multiple simultaneous updates. + case conflict = 409 + + /// - gone: Indicates that the resource requested is no longer available and will not be available again. + case gone = 410 + + /// - lengthRequired: The request did not specify the length of its content, which is required by the requested resource. + case lengthRequired = 411 + + /// - preconditionFailed: The server does not meet one of the preconditions that the requester put on the request. + case preconditionFailed = 412 + + /// - payloadTooLarge: The request is larger than the server is willing or able to process. + case payloadTooLarge = 413 + + /// - URITooLong: The URI provided was too long for the server to process. + case URITooLong = 414 + + /// - unsupportedMediaType: The request entity has a media type which the server or resource does not support. + case unsupportedMediaType = 415 + + /// - rangeNotSatisfiable: The client has asked for a portion of the file (byte serving), but the server cannot supply that portion. + case rangeNotSatisfiable = 416 + + /// - expectationFailed: The server cannot meet the requirements of the Expect request-header field. + case expectationFailed = 417 + + /// - teapot: This HTTP status is used as an Easter egg in some websites. + case teapot = 418 + + /// - misdirectedRequest: The request was directed at a server that is not able to produce a response. + case misdirectedRequest = 421 + + /// - unprocessableEntity: The request was well-formed but was unable to be followed due to semantic errors. + case unprocessableEntity = 422 + + /// - locked: The resource that is being accessed is locked. + case locked = 423 + + /// - failedDependency: The request failed due to failure of a previous request (e.g., a PROPPATCH). + case failedDependency = 424 + + /// - upgradeRequired: The client should switch to a different protocol such as TLS/1.0, given in the Upgrade header field. + case upgradeRequired = 426 + + /// - preconditionRequired: The origin server requires the request to be conditional. + case preconditionRequired = 428 + + /// - tooManyRequests: The user has sent too many requests in a given amount of time. + case tooManyRequests = 429 + + /// - requestHeaderFieldsTooLarge: The server is unwilling to process the request because either an individual header field, or all the header fields collectively, are too large. + case requestHeaderFieldsTooLarge = 431 + + /// - noResponse: Used to indicate that the server has returned no information to the client and closed the connection. + case noResponse = 444 + + /// - unavailableForLegalReasons: A server operator has received a legal demand to deny access to a resource or to a set of resources that includes the requested resource. + case unavailableForLegalReasons = 451 + + /// - SSLCertificateError: An expansion of the 400 Bad Request response code, used when the client has provided an invalid client certificate. + case SSLCertificateError = 495 + + /// - SSLCertificateRequired: An expansion of the 400 Bad Request response code, used when a client certificate is required but not provided. + case SSLCertificateRequired = 496 + + /// - HTTPRequestSentToHTTPSPort: An expansion of the 400 Bad Request response code, used when the client has made a HTTP request to a port listening for HTTPS requests. + case HTTPRequestSentToHTTPSPort = 497 + + /// - clientClosedRequest: Used when the client has closed the request before the server could send a response. + case clientClosedRequest = 499 + + // + // Server Error - 5xx + // + + /// - internalServerError: A generic error message, given when an unexpected condition was encountered and no more specific message is suitable. + case internalServerError = 500 + + /// - notImplemented: The server either does not recognize the request method, or it lacks the ability to fulfill the request. + case notImplemented = 501 + + /// - badGateway: The server was acting as a gateway or proxy and received an invalid response from the upstream server. + case badGateway = 502 + + /// - serviceUnavailable: The server is currently unavailable (because it is overloaded or down for maintenance). Generally, this is a temporary state. + case serviceUnavailable = 503 + + /// - gatewayTimeout: The server was acting as a gateway or proxy and did not receive a timely response from the upstream server. + case gatewayTimeout = 504 + + /// - HTTPVersionNotSupported: The server does not support the HTTP protocol version used in the request. + case HTTPVersionNotSupported = 505 + + /// - variantAlsoNegotiates: Transparent content negotiation for the request results in a circular reference. + case variantAlsoNegotiates = 506 + + /// - insufficientStorage: The server is unable to store the representation needed to complete the request. + case insufficientStorage = 507 + + /// - loopDetected: The server detected an infinite loop while processing the request. + case loopDetected = 508 + + /// - notExtended: Further extensions to the request are required for the server to fulfill it. + case notExtended = 510 + + /// - networkAuthenticationRequired: The client needs to authenticate to gain network access. + case networkAuthenticationRequired = 511 + + /// The class (or group) which the status code belongs to. + var responseType: ResponseType { + switch self.rawValue { + case 100..<200: + return .informational + + case 200..<300: + return .success + + case 300..<400: + return .redirection + + case 400..<500: + return .clientError + + case 500..<600: + return .serverError + + default: + return .undefined + } + } +} + +extension HTTPURLResponse { + var status: HTTPStatusCode? { + return HTTPStatusCode(rawValue: statusCode) + } +} diff --git a/Sources/Confidence/Http/NetworkClient.swift b/Sources/Confidence/Http/NetworkClient.swift new file mode 100644 index 00000000..bbce90a9 --- /dev/null +++ b/Sources/Confidence/Http/NetworkClient.swift @@ -0,0 +1,170 @@ +import Foundation + +final class NetworkClient: HttpClient { + private let headers: [String: String] + private let retry: Retry + private let timeout: TimeInterval + private let session: URLSession + private let region: ConfidenceRegion + + private var baseUrl: String { + switch region { + case .global: + return "https://events.confidence.dev/v1/events" + case .europe: + return "https://events.eu.confidence.dev/v1/events" + case .usa: + return "https://events.us.confidence.dev/v1/events" + } + } + + init( + session: URLSession? = nil, + region: ConfidenceRegion, + defaultHeaders: [String: String] = [:], + timeout: TimeInterval = 30.0, + retry: Retry = .none + ) { + self.session = + session + ?? { + let configuration = URLSessionConfiguration.default + configuration.timeoutIntervalForRequest = timeout + configuration.httpAdditionalHeaders = defaultHeaders + + return URLSession(configuration: configuration) + }() + + self.headers = defaultHeaders + self.retry = retry + self.timeout = timeout + self.region = region + } + + func post( + path: String, + data: Encodable + ) async throws -> HttpClientResult { + let request = try buildRequest(path: path, data: data) + let requestResult = await perform(request: request, retry: self.retry) + if let error = requestResult.error { + return .failure(error) + } + + guard let response = requestResult.httpResponse, let data = requestResult.data else { + return .failure(ConfidenceError.internalError(message: "Bad response")) + } + + do { + let httpClientResult: HttpClientResponse = + try self.buildResponse(response: response, data: data) + return .success(httpClientResult) + } catch { + return .failure(error) + } + } + + private func perform( + request: URLRequest, + retry: Retry + ) async -> RequestResult { + let retryHandler = retry.handler() + let retryWait: TimeInterval? = retryHandler.retryIn() + + do { + let (data, response) = try await self.session.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + return RequestResult(httpResponse: nil, data: nil, error: HttpClientError.invalidResponse) + } + if self.shouldRetry(httpResponse: httpResponse), let retryWait { + try? await Task.sleep(nanoseconds: UInt64(retryWait * 1_000_000_000)) + return await self.perform(request: request, retry: retry) + } + return RequestResult(httpResponse: httpResponse, data: data, error: nil) + } catch { + if self.shouldRetry(error: error), let retryWait { + try? await Task.sleep(nanoseconds: UInt64(retryWait * 1_000_000_000)) + return await self.perform(request: request, retry: retry) + } else { + return RequestResult(httpResponse: nil, data: nil, error: error) + } + } + } +} + +struct RequestResult { + var httpResponse: HTTPURLResponse? + var data: Data? + var error: Error? +} + +// MARK: Private + +extension NetworkClient { + private func constructURL(base: String, path: String) -> URL? { + let normalisedBase = base.hasSuffix("/") ? base : "\(base)" + let normalisedPath = path.hasPrefix("/") ? String(path.dropFirst()) : path + + return URL(string: "\(normalisedBase)\(normalisedPath)") + } + + private func buildRequest(path: String, data: Encodable) throws -> URLRequest { + guard let url = constructURL(base: baseUrl, path: path) else { + throw ConfidenceError.internalError(message: "Could not create service url") + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + request.addValue("application/json", forHTTPHeaderField: "Accept") + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + + let jsonData = try encoder.encode(data) + request.httpBody = jsonData + + return request + } + + private func buildResponse( + response httpURLResponse: HTTPURLResponse?, + data: Data? + ) throws -> HttpClientResponse { + guard let httpURLResponse else { + throw ConfidenceError.internalError(message: "Invalid response") + } + + var response: HttpClientResponse = HttpClientResponse(response: httpURLResponse) + if let responseData = data { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + if response.response.status == .ok { + // TODO: Inspect and log errors + response.decodedData = try decoder.decode(T.self, from: responseData) + } else { + do { + response.decodedError = try decoder.decode(HttpError.self, from: responseData) + } catch { + let message = String(data: responseData, encoding: String.Encoding.utf8) + response.decodedError = HttpError( + code: httpURLResponse.statusCode, + message: message ?? "unknown", + details: [] + ) + } + } + } + + return response + } + + private func shouldRetry(httpResponse: HTTPURLResponse) -> Bool { + httpResponse.status?.responseType == .serverError + } + + private func shouldRetry(error: Error) -> Bool { + (error as? URLError)?.code == .timedOut + } +} diff --git a/Sources/Confidence/Http/Retry.swift b/Sources/Confidence/Http/Retry.swift new file mode 100644 index 00000000..892e5261 --- /dev/null +++ b/Sources/Confidence/Http/Retry.swift @@ -0,0 +1,47 @@ +import Foundation + +enum Retry { + case none + case exponential(maxBackoff: TimeInterval, maxAttempts: UInt) + + func handler() -> RetryHandler { + switch self { + case .none: + return NoneRetryHandler() + case let .exponential(maxBackoff, maxAttempts): + return ExponentialBackoffRetryHandler(maxBackoff: maxBackoff, maxAttempts: maxAttempts) + } + } +} + +protocol RetryHandler { + func retryIn() -> TimeInterval? +} + +class ExponentialBackoffRetryHandler: RetryHandler { + private var currentAttempts: UInt = 0 + private let maxBackoff: TimeInterval + private let maxAttempts: UInt + + init(maxBackoff: TimeInterval, maxAttempts: UInt) { + self.maxBackoff = maxBackoff + self.maxAttempts = maxAttempts + } + + func retryIn() -> TimeInterval? { + if currentAttempts >= maxAttempts { + return nil + } + + let nextRetryTime = min(pow(2, Double(currentAttempts)) + Double.random(in: 0..<1), maxBackoff) + + currentAttempts += 1 + return nextRetryTime + } +} + +class NoneRetryHandler: RetryHandler { + func retryIn() -> TimeInterval? { + return nil + } +} diff --git a/Tests/ConfidenceTests/ConfidenceTests.swift b/Tests/ConfidenceTests/ConfidenceTests.swift index b77e05a5..2f58cafd 100644 --- a/Tests/ConfidenceTests/ConfidenceTests.swift +++ b/Tests/ConfidenceTests/ConfidenceTests.swift @@ -8,7 +8,8 @@ final class ConfidenceTests: XCTestCase { timeout: TimeInterval(), region: .europe, initializationStrategy: .activateAndFetchAsync, - context: ["k1": ConfidenceValue(string: "v1")] + context: ["k1": ConfidenceValue(string: "v1")], + client: ConfidenceClientMock() ) let confidenceChild: ConfidenceEventSender = confidenceParent.withContext( ["k2": ConfidenceValue(string: "v2")] @@ -26,7 +27,8 @@ final class ConfidenceTests: XCTestCase { timeout: TimeInterval(), region: .europe, initializationStrategy: .activateAndFetchAsync, - context: ["k1": ConfidenceValue(string: "v1")] + context: ["k1": ConfidenceValue(string: "v1")], + client: ConfidenceClientMock() ) let confidenceChild: ConfidenceEventSender = confidenceParent.withContext( ["k2": ConfidenceValue(string: "v2")] @@ -48,7 +50,8 @@ final class ConfidenceTests: XCTestCase { timeout: TimeInterval(), region: .europe, initializationStrategy: .activateAndFetchAsync, - context: ["k1": ConfidenceValue(string: "v1")] + context: ["k1": ConfidenceValue(string: "v1")], + client: ConfidenceClientMock() ) confidence.updateContextEntry( key: "k1", @@ -65,7 +68,8 @@ final class ConfidenceTests: XCTestCase { timeout: TimeInterval(), region: .europe, initializationStrategy: .activateAndFetchAsync, - context: ["k1": ConfidenceValue(string: "v1")] + context: ["k1": ConfidenceValue(string: "v1")], + client: ConfidenceClientMock() ) let confidenceChild: ConfidenceEventSender = confidenceParent.withContext( ["k2": ConfidenceValue(string: "v2")] @@ -86,7 +90,8 @@ final class ConfidenceTests: XCTestCase { timeout: TimeInterval(), region: .europe, initializationStrategy: .activateAndFetchAsync, - context: ["k1": ConfidenceValue(string: "v1")] + context: ["k1": ConfidenceValue(string: "v1")], + client: ConfidenceClientMock() ) let confidenceChild: ConfidenceEventSender = confidenceParent.withContext( ["k2": ConfidenceValue(string: "v2")] @@ -110,7 +115,8 @@ final class ConfidenceTests: XCTestCase { context: [ "k1": ConfidenceValue(string: "v1"), "k2": ConfidenceValue(string: "v2") - ] + ], + client: ConfidenceClientMock() ) confidence.removeContextEntry(key: "k2") let expected = [ @@ -125,7 +131,8 @@ final class ConfidenceTests: XCTestCase { timeout: TimeInterval(), region: .europe, initializationStrategy: .activateAndFetchAsync, - context: ["k1": ConfidenceValue(string: "v1")] + context: ["k1": ConfidenceValue(string: "v1")], + client: ConfidenceClientMock() ) let confidenceChild: ConfidenceEventSender = confidenceParent.withContext( ["k2": ConfidenceValue(string: "v2")] @@ -143,7 +150,8 @@ final class ConfidenceTests: XCTestCase { timeout: TimeInterval(), region: .europe, initializationStrategy: .activateAndFetchAsync, - context: ["k1": ConfidenceValue(string: "v1")] + context: ["k1": ConfidenceValue(string: "v1")], + client: ConfidenceClientMock() ) let confidenceChild: ConfidenceEventSender = confidenceParent.withContext( [ @@ -164,7 +172,8 @@ final class ConfidenceTests: XCTestCase { timeout: TimeInterval(), region: .europe, initializationStrategy: .activateAndFetchAsync, - context: ["k1": ConfidenceValue(string: "v1")] + context: ["k1": ConfidenceValue(string: "v1")], + client: ConfidenceClientMock() ) let confidenceChild: ConfidenceEventSender = confidenceParent.withContext( [ diff --git a/Tests/ConfidenceTests/Helpers/ConfidenceClientMock.swift b/Tests/ConfidenceTests/Helpers/ConfidenceClientMock.swift new file mode 100644 index 00000000..7ff5644a --- /dev/null +++ b/Tests/ConfidenceTests/Helpers/ConfidenceClientMock.swift @@ -0,0 +1,9 @@ +import Foundation + +@testable import Confidence + +class ConfidenceClientMock: ConfidenceClient { + func send(definition: String, payload: ConfidenceStruct) async throws { + // NO-OP + } +}