Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(ats): add ODP integration #455

Merged
merged 116 commits into from
Oct 21, 2022
Merged
Show file tree
Hide file tree
Changes from 112 commits
Commits
Show all changes
116 commits
Select commit Hold shift + click to select a range
60f0a2d
wip
jaeopt Jan 13, 2022
6c01e73
recover sample app
jaeopt Jan 13, 2022
ca76a1d
Merge branch 'master' into jae/list-target
jaeopt Mar 16, 2022
a0ec550
add optional AudienceSegmentsHandler
jaeopt Mar 16, 2022
1bd224a
clean up audience segments
jaeopt Mar 17, 2022
f68e7d3
add config params
jaeopt Mar 22, 2022
ce958bc
add tests for OptimizelyUserContext + ATS
jaeopt Apr 5, 2022
d5721b3
add protocol for AudienceSegmentsHandler
jaeopt Apr 5, 2022
5fb8a20
more tests for segments
jaeopt Apr 6, 2022
619d524
add segments to audience
jaeopt Apr 6, 2022
3a441e0
Merge branch 'master' into jae/list-target
jaeopt Apr 11, 2022
e9a026b
add segments tests
jaeopt Apr 12, 2022
e43cbf6
add tests for ATS
jaeopt Apr 14, 2022
553a350
add segmentsToCheck filtering
jaeopt Apr 14, 2022
cc28acb
thread-safe audienceSegmentsHandler
jaeopt Apr 15, 2022
afd0e83
clean up
jaeopt Apr 15, 2022
532656b
add segmentsToCheck support
jaeopt Apr 15, 2022
6a4ef83
add useSubset option
jaeopt Apr 15, 2022
bb541f7
add performance test for user context
jaeopt Apr 18, 2022
4e524f1
clean up user context
jaeopt Apr 18, 2022
6d0ea43
clean up user context synchronizations
jaeopt Apr 19, 2022
12cd3a8
add user context performance tests
jaeopt Apr 19, 2022
4eea2af
fix fetch api to return segments in completion handler
jaeopt Apr 19, 2022
71e6fa6
clean up
jaeopt Apr 19, 2022
b339b66
fix for old swift in ci
jaeopt Apr 19, 2022
87e7130
clean up
jaeopt Apr 19, 2022
be79815
skip debug-mode tests in CI
jaeopt Apr 20, 2022
5482a84
exclude performance tests from ci
jaeopt Apr 20, 2022
6a70194
fix to support odp integration in datafile
jaeopt Apr 22, 2022
93ca111
fix datafile error
jaeopt May 2, 2022
2333bd0
clean up per zaius api changes
jaeopt May 3, 2022
7a964bd
samples for odp register
jaeopt May 6, 2022
92cd072
remove useSubset option
jaeopt May 10, 2022
c5961a3
Merge branch 'master' into jae/ats
jaeopt May 10, 2022
b4e2862
fix branch ref in github action workflow
jaeopt May 10, 2022
3db0ff4
fix tests for default segments
jaeopt May 10, 2022
f329a2a
fix odp rest api error
jaeopt May 12, 2022
3e88249
add email to odp profile
jaeopt May 12, 2022
41b67ee
remove vuid and fix fs_user_id based queries
jaeopt May 12, 2022
13a59ae
adding vuid support
jaeopt Jun 10, 2022
f6d05e7
clean up vuid support
jaeopt Jun 11, 2022
dd5c82b
vuid local ready
jaeopt Jun 12, 2022
7e90f5a
refactor odpManager
jaeopt Jun 12, 2022
e810143
refact ODP modules
jaeopt Jun 24, 2022
a747d2c
create user context with vuid
jaeopt Jun 24, 2022
980cbdf
fix ODPEventsManager for reliable and batch dispatch
jaeopt Jun 25, 2022
3601932
vuid manager singleton
jaeopt Jun 27, 2022
1770ada
odp events ready
jaeopt Jun 29, 2022
17b1167
merge vuid extensions
jaeopt Jun 29, 2022
564bbe9
update odp event format
jaeopt Jul 1, 2022
3cbbdbf
error handling for odp events
jaeopt Jul 1, 2022
6c34860
fix odp event format
jaeopt Jul 1, 2022
5363800
add tests for graphql and events apis
jaeopt Jul 1, 2022
77f2ee7
fix tests for ODPSegmentManager
jaeopt Jul 1, 2022
4030b7f
add odp event tests
jaeopt Jul 2, 2022
09647bb
add more tests for ODP
jaeopt Jul 6, 2022
d12c122
fix odp tests
jaeopt Jul 8, 2022
73062fa
more odp tests
jaeopt Jul 8, 2022
273e282
add more odp tests
jaeopt Jul 8, 2022
25a0d78
Merge branch 'master' into jae/ats
jaeopt Jul 8, 2022
7974e8e
testing github actions
jaeopt Jul 11, 2022
e0eadb2
remove odp event auto retries
jaeopt Jul 11, 2022
72deb05
fix disableOdp configuration
jaeopt Jul 11, 2022
d24fd1b
Merge branch 'master' into jae/ats
jaeopt Jul 12, 2022
8af4457
Merge branch 'master' into jae/ats
jaeopt Jul 12, 2022
cee47dc
fix event data device values
jaeopt Jul 12, 2022
375c700
Merge branch 'master' into jae/ats
jaeopt Jul 12, 2022
fdb2e14
fix device info for watchOS
jaeopt Jul 12, 2022
57a3c3f
clean up user context init
jaeopt Jul 13, 2022
cafceae
refact OptimizelyUserContext
jaeopt Jul 13, 2022
a84eba5
Merge branch 'jae/ats' of github.com:optimizely/swift-sdk into jae/ats
jaeopt Jul 13, 2022
d19e10e
fix lint for FSC old swift versions
jaeopt Jul 13, 2022
904a0e6
add zero timeout support to LruCache
jaeopt Jul 13, 2022
2200847
clean up odp event retries
jaeopt Jul 15, 2022
33f9373
move integrations tests
jaeopt Jul 15, 2022
80f40da
qualifedSegments set to nil when fetch fails
jaeopt Jul 20, 2022
2b15b5c
integrations foward compat test
jaeopt Jul 25, 2022
cd18ac0
fix graphql request examples
jaeopt Jul 25, 2022
f1eaa1a
Merge branch 'master' into jae/ats
jaeopt Jul 25, 2022
cefb5e2
add doc comments
jaeopt Jul 26, 2022
84ff4e1
add guard for negative segments cache size
jaeopt Jul 26, 2022
11b7dfe
remve reset on allStale from segments cache
jaeopt Jul 27, 2022
edf3595
clean up
jaeopt Jul 28, 2022
86679c5
Merge branch 'master' into jae/ats
jaeopt Jul 29, 2022
7a218bd
fix vuid length to 32
jaeopt Jul 29, 2022
f60bb97
clean up demo app
jaeopt Aug 3, 2022
4ab9636
reset segments cache on datafile update
jaeopt Aug 3, 2022
2869909
fix segmentsToCheck tests
jaeopt Aug 3, 2022
4876d07
refact OdpManager
jaeopt Aug 4, 2022
8ef2383
fix tests
jaeopt Aug 4, 2022
2d746db
clean up odpConfig link
jaeopt Aug 4, 2022
e1b6924
refact updateOdpConfig
jaeopt Aug 4, 2022
3bb769f
add reset test for lruCache
jaeopt Aug 4, 2022
d8f4d10
filtering out redundant segments
jaeopt Aug 10, 2022
622832f
clean up tests
jaeopt Aug 10, 2022
959ddeb
clean up
jaeopt Aug 16, 2022
f7309ee
change vuid storage to flat structured
jaeopt Aug 17, 2022
84875a4
add error filtering to sendOdpEvent
jaeopt Aug 19, 2022
ab7064d
fix sendOdpEvent queued when datafile is not ready
jaeopt Aug 23, 2022
81d7a2f
add more odp event tests
jaeopt Aug 24, 2022
544e3f6
Merge branch 'master' into jae/ats
jaeopt Aug 24, 2022
12dc1e9
Merge branch 'master' into jae/ats
jaeopt Aug 24, 2022
a971b8e
add odp event data type checking
jaeopt Aug 25, 2022
2a38cb1
more tests for odp event data
jaeopt Aug 25, 2022
a21e2b3
add odp flush when app goes background
jaeopt Sep 12, 2022
33e4507
fix odp event tests for old OS
jaeopt Sep 13, 2022
2f65b6c
clean up
jaeopt Sep 13, 2022
2ca7bde
add log
jaeopt Sep 15, 2022
e7436b2
discard remainging odp events after config changed
jaeopt Sep 15, 2022
9e72461
odpconfig optional for segmentsManager and eventsManager
jaeopt Sep 19, 2022
d44409d
resolve pr reviews
jaeopt Sep 30, 2022
bb7ed98
Merge branch 'master' into jae/ats
jaeopt Sep 30, 2022
b0d7fcd
rename zaius to apiManager
jaeopt Oct 7, 2022
ec83c87
remove odp events reset on update config
jaeopt Oct 7, 2022
4a1662f
change segments query to variables based
jaeopt Oct 21, 2022
3336149
Merge branch 'master' into jae/ats
jaeopt Oct 21, 2022
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
2 changes: 2 additions & 0 deletions DemoSwiftApp/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -130,11 +130,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
@unknown default:
print("Optimizely SDK initiliazation failed with unknown result")
}

self.startWithRootViewController()

// For sample codes for APIs, see "Samples/SamplesForAPI.swift"
//SamplesForAPI.checkOptimizelyConfig(optimizely: self.optimizely)
//SamplesForAPI.checkOptimizelyUserContext(optimizely: self.optimizely)
//SamplesForAPI.checkAudienceSegments(optimizely: self.optimizely)
}
}

Expand Down
2 changes: 1 addition & 1 deletion DemoSwiftApp/DemoSwiftApp.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -555,7 +555,7 @@
isa = PBXProject;
attributes = {
LastSwiftUpdateCheck = 1230;
LastUpgradeCheck = 1250;
LastUpgradeCheck = 1320;
ORGANIZATIONNAME = Optimizely;
TargetAttributes = {
252D7DEC21C8800800134A7A = {
Expand Down
36 changes: 31 additions & 5 deletions DemoSwiftApp/Samples/SamplesForAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

import Foundation
import Optimizely
import UIKit

class SamplesForAPI {

Expand Down Expand Up @@ -146,17 +147,17 @@ class SamplesForAPI {

// (1) set a forced decision for a flag

var success = user.setForcedDecision(context: context1, decision: forced1)
_ = user.setForcedDecision(context: context1, decision: forced1)
decision = user.decide(key: "flag-1")

// (2) set a forced decision for an ab-test rule

success = user.setForcedDecision(context: context2, decision: forced2)
_ = user.setForcedDecision(context: context2, decision: forced2)
decision = user.decide(key: "flag-1")

// (3) set a forced variation for a delivery rule

success = user.setForcedDecision(context: context3,
_ = user.setForcedDecision(context: context3,
decision: forced3)
decision = user.decide(key: "flag-1")

Expand All @@ -167,8 +168,8 @@ class SamplesForAPI {

// (5) remove forced variations

success = user.removeForcedDecision(context: context2)
success = user.removeAllForcedDecisions()
_ = user.removeForcedDecision(context: context2)
_ = user.removeAllForcedDecisions()
}

// MARK: - OptimizelyConfig
Expand Down Expand Up @@ -260,6 +261,31 @@ class SamplesForAPI {

}

// MARK: - AudienceSegments

static func checkAudienceSegments(optimizely: OptimizelyClient) {
// override the default handler if cache size and timeout need to be customized
let optimizely = OptimizelyClient(sdkKey: "FCnSegiEkRry9rhVMroit4",
periodicDownloadInterval: 60)
optimizely.start { result in
if case .failure(let error) = result {
print("[AudienceSegments] SDK initialization failed: \(error)")
return
}

let user = optimizely.createUserContext(userId: "user_123", attributes: ["location": "NY"])
user.fetchQualifiedSegments(options: [.ignoreCache]) { _, error in
guard error == nil else {
print("[AudienceSegments] \(error!.errorDescription!)")
return
}

let decision = user.decide(key: "show_coupon", options: [.includeReasons])
print("[AudienceSegments] decision: \(decision)")
}
}
}

// MARK: - Initializations

static func samplesForInitialization() {
Expand Down
614 changes: 579 additions & 35 deletions OptimizelySwiftSDK.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<dict>
<key>FILEHEADER</key>
<string>
// Copyright 2021, Optimizely, Inc. and contributors
// Copyright 2022, Optimizely, Inc. and contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
Expand Down
32 changes: 30 additions & 2 deletions Sources/Data Model/Audience/Audience.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,36 @@ struct Audience: Codable, Equatable, OptimizelyAudience {
try container.encode(conditionHolder, forKey: .conditions)
}

func evaluate(project: ProjectProtocol?, attributes: OptimizelyAttributes?) throws -> Bool {
return try conditionHolder.evaluate(project: project, attributes: attributes)
func evaluate(project: ProjectProtocol?, user: OptimizelyUserContext) throws -> Bool {
return try conditionHolder.evaluate(project: project, user: user)
}

/// Extract all audience segments used in this audience conditions.
/// - Returns: a String array of segment names.
func getSegments() -> [String] {
let segments = getSegments(condition: conditionHolder)
return Array(Set(segments))
}

func getSegments(condition: ConditionHolder) -> [String] {
var segments = [String]()

switch condition {
case .logicalOp:
return []
case .leaf(let leaf):
if case .attribute(let userAttribute) = leaf {
if userAttribute.matchSupported == .qualified, let strValue = userAttribute.value?.stringValue {
segments.append(strValue)
}
}
case .array(let conditions):
conditions.forEach {
segments.append(contentsOf: getSegments(condition: $0))
}
}

return segments
}

}
16 changes: 8 additions & 8 deletions Sources/Data Model/Audience/ConditionHolder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,14 +61,14 @@ enum ConditionHolder: Codable, Equatable {
}
}

func evaluate(project: ProjectProtocol?, attributes: OptimizelyAttributes?) throws -> Bool {
func evaluate(project: ProjectProtocol?, user: OptimizelyUserContext) throws -> Bool {
switch self {
case .logicalOp:
throw OptimizelyError.conditionInvalidFormat("Logical operation not evaluated")
case .leaf(let conditionLeaf):
return try conditionLeaf.evaluate(project: project, attributes: attributes)
return try conditionLeaf.evaluate(project: project, user: user)
case .array(let conditions):
return try conditions.evaluate(project: project, attributes: attributes)
return try conditions.evaluate(project: project, user: user)
}
}

Expand Down Expand Up @@ -111,24 +111,24 @@ extension ConditionHolder {

extension Array where Element == ConditionHolder {

func evaluate(project: ProjectProtocol?, attributes: OptimizelyAttributes?) throws -> Bool {
func evaluate(project: ProjectProtocol?, user: OptimizelyUserContext) throws -> Bool {
guard let firstItem = self.first else {
throw OptimizelyError.conditionInvalidFormat("Empty condition array")
}

switch firstItem {
case .logicalOp(let op):
return try evaluate(op: op, project: project, attributes: attributes)
return try evaluate(op: op, project: project, user: user)
case .leaf:
// special case - no logical operator
// implicit or
return try [[ConditionHolder.logicalOp(.or)], self].flatMap({$0}).evaluate(op: LogicalOp.or, project: project, attributes: attributes)
return try [[ConditionHolder.logicalOp(.or)], self].flatMap({$0}).evaluate(op: LogicalOp.or, project: project, user: user)
default:
throw OptimizelyError.conditionInvalidFormat("Invalid first item")
}
}

func evaluate(op: LogicalOp, project: ProjectProtocol?, attributes: OptimizelyAttributes?) throws -> Bool {
func evaluate(op: LogicalOp, project: ProjectProtocol?, user: OptimizelyUserContext) throws -> Bool {
guard self.count > 0 else {
throw OptimizelyError.conditionInvalidFormat("Empty condition array")
}
Expand All @@ -138,7 +138,7 @@ extension Array where Element == ConditionHolder {
// create closure array for delayed evaluations to avoid unnecessary ops
let evalList = itemsAfterOpTrimmed.map { holder -> ThrowableCondition in
return {
return try holder.evaluate(project: project, attributes: attributes)
return try holder.evaluate(project: project, user: user)
}
}

Expand Down
6 changes: 3 additions & 3 deletions Sources/Data Model/Audience/ConditionLeaf.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,16 +45,16 @@ enum ConditionLeaf: Codable, Equatable {
}
}

func evaluate(project: ProjectProtocol?, attributes: OptimizelyAttributes?) throws -> Bool {
func evaluate(project: ProjectProtocol?, user: OptimizelyUserContext) throws -> Bool {
switch self {
case .audienceId(let id):
guard let project = project else {
throw OptimizelyError.conditionCannotBeEvaluated("audienceId: \(id)")
}

return try project.evaluateAudience(audienceId: id, attributes: attributes)
return try project.evaluateAudience(audienceId: id, user: user)
case .attribute(let userAttribute):
return try userAttribute.evaluate(attributes: attributes)
return try userAttribute.evaluate(user: user)
}
}

Expand Down
68 changes: 42 additions & 26 deletions Sources/Data Model/Audience/UserAttribute.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ struct UserAttribute: Codable, Equatable {

enum ConditionType: String, Codable {
case customAttribute = "custom_attribute"
case thirdPartyDimension = "third_party_dimension"
}

enum ConditionMatch: String, Codable {
Expand All @@ -52,6 +53,7 @@ struct UserAttribute: Codable, Equatable {
case semver_le
case semver_gt
case semver_ge
case qualified
}

var typeSupported: ConditionType? {
Expand Down Expand Up @@ -98,7 +100,7 @@ struct UserAttribute: Codable, Equatable {

extension UserAttribute {

func evaluate(attributes: OptimizelyAttributes?) throws -> Bool {
func evaluate(user: OptimizelyUserContext) throws -> Bool {

// invalid type - parsed for forward compatibility only (but evaluation fails)
if typeSupported == nil {
Expand All @@ -114,63 +116,77 @@ extension UserAttribute {
throw OptimizelyError.userAttributeInvalidName(stringRepresentation)
}

let attributes = attributes ?? OptimizelyAttributes()

let rawAttributeValue = attributes[nameFinal] ?? nil // default to nil to avoid warning "coerced from 'Any??' to 'Any?'"
let attributes = user.attributes
let rawValue = attributes[nameFinal] ?? nil // default to nil to avoid warning "coerced from 'Any??' to 'Any?'"

if matchFinal != .exists {
if !attributes.keys.contains(nameFinal) {
throw OptimizelyError.missingAttributeValue(stringRepresentation, nameFinal)
}
if matchFinal == .exists {
return !(rawValue is NSNull || rawValue == nil)
}

// all other matches requires valid value

if value == nil {
throw OptimizelyError.userAttributeNilValue(stringRepresentation)
}
guard let value = value else {
throw OptimizelyError.userAttributeNilValue(stringRepresentation)
}

if rawAttributeValue == nil {
throw OptimizelyError.nilAttributeValue(stringRepresentation, nameFinal)
if matchFinal == .qualified {
// NOTE: name ("odp.audiences") and type("third_party_dimension") not used

guard case .string(let strValue) = value else {
throw OptimizelyError.evaluateAttributeInvalidCondition(stringRepresentation)
}
return user.isQualifiedFor(segment: strValue)
}

// all other matches requires attribute value

guard attributes.keys.contains(nameFinal) else {
throw OptimizelyError.missingAttributeValue(stringRepresentation, nameFinal)
}

guard let rawAttributeValue = rawValue else {
throw OptimizelyError.nilAttributeValue(stringRepresentation, nameFinal)
}

switch matchFinal {
case .exists:
return !(rawAttributeValue is NSNull || rawAttributeValue == nil)
case .exact:
return try value!.isExactMatch(with: rawAttributeValue!, condition: stringRepresentation, name: nameFinal)
return try value.isExactMatch(with: rawAttributeValue, condition: stringRepresentation, name: nameFinal)
case .substring:
return try value!.isSubstring(of: rawAttributeValue!, condition: stringRepresentation, name: nameFinal)
return try value.isSubstring(of: rawAttributeValue, condition: stringRepresentation, name: nameFinal)
case .lt:
// user attribute "less than" this condition value
// so evaluate if this condition value "isGreater" than the user attribute value
return try value!.isGreater(than: rawAttributeValue!, condition: stringRepresentation, name: nameFinal)
return try value.isGreater(than: rawAttributeValue, condition: stringRepresentation, name: nameFinal)
case .le:
// user attribute "less than" or equal this condition value
// so evaluate if this condition value "isGreater" than or equal the user attribute value
return try value!.isGreaterOrEqual(than: rawAttributeValue!, condition: stringRepresentation, name: nameFinal)
return try value.isGreaterOrEqual(than: rawAttributeValue, condition: stringRepresentation, name: nameFinal)
case .gt:
// user attribute "greater than" this condition value
// so evaluate if this condition value "isLess" than the user attribute value
return try value!.isLess(than: rawAttributeValue!, condition: stringRepresentation, name: nameFinal)
return try value.isLess(than: rawAttributeValue, condition: stringRepresentation, name: nameFinal)
case .ge:
// user attribute "greater than or equal" this condition value
// so evaluate if this condition value "isLess" than or equal the user attribute value
return try value!.isLessOrEqual(than: rawAttributeValue!, condition: stringRepresentation, name: nameFinal)
return try value.isLessOrEqual(than: rawAttributeValue, condition: stringRepresentation, name: nameFinal)
// semantic versioning seems unique. the comarison is to compare verion but the passed in version is the target version.
case .semver_eq:
let targetValue = try targetAsAttributeValue(value: rawAttributeValue, attribute: value, nameFinal: nameFinal)
return try targetValue.isSemanticVersionEqual(than: value!.stringValue)
return try targetValue.isSemanticVersionEqual(than: value.stringValue)
case .semver_lt:
let targetValue = try targetAsAttributeValue(value: rawAttributeValue, attribute: value, nameFinal: nameFinal)
return try targetValue.isSemanticVersionLess(than: value!.stringValue)
return try targetValue.isSemanticVersionLess(than: value.stringValue)
case .semver_le:
let targetValue = try targetAsAttributeValue(value: rawAttributeValue, attribute: value, nameFinal: nameFinal)
return try targetValue.isSemanticVersionLessOrEqual(than: value!.stringValue)
return try targetValue.isSemanticVersionLessOrEqual(than: value.stringValue)
case .semver_gt:
let targetValue = try targetAsAttributeValue(value: rawAttributeValue, attribute: value, nameFinal: nameFinal)
return try targetValue.isSemanticVersionGreater(than: value!.stringValue)
return try targetValue.isSemanticVersionGreater(than: value.stringValue)
case .semver_ge:
let targetValue = try targetAsAttributeValue(value: rawAttributeValue, attribute: value, nameFinal: nameFinal)
return try targetValue.isSemanticVersionGreaterOrEqual(than: value!.stringValue)
return try targetValue.isSemanticVersionGreaterOrEqual(than: value.stringValue)
default:
throw OptimizelyError.userAttributeInvalidMatch(stringRepresentation)
}
}

Expand Down
23 changes: 23 additions & 0 deletions Sources/Data Model/Integration.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
//
// Copyright 2022, Optimizely, Inc. and contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//

import Foundation

struct Integration: Codable, Equatable {
var key: String
var host: String?
var publicKey: String?
}
Loading