├── .github └── workflows │ └── Test.yml ├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── LICENSE.txt ├── Package.swift ├── README.md ├── Sources └── Ahoy │ ├── Ahoy.swift │ ├── AhoyError.swift │ ├── Codable+Ahoy.swift │ ├── Configuration.swift │ ├── Environment.swift │ ├── Event.swift │ ├── ExpiringPersisted.swift │ ├── Publisher+Ahoy.swift │ ├── RequestInterceptor.swift │ ├── TokenManager.swift │ └── Visit.swift └── Tests ├── AhoyTests ├── AhoyTests.swift └── XCTestManifests.swift └── LinuxMain.swift /.github/workflows/Test.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: macos-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Build 17 | run: swift build -v 18 | - name: Run tests 19 | run: swift test -v 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData 7 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Dan Loman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.2 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "Ahoy", 7 | platforms: [ 8 | .iOS(.v13), 9 | .macOS(.v10_15), 10 | .tvOS(.v13), 11 | .watchOS("6.2") 12 | ], 13 | products: [ 14 | .library(name: "Ahoy", targets: ["Ahoy"]), 15 | ], 16 | targets: [ 17 | .target(name: "Ahoy", dependencies: []), 18 | .testTarget(name: "AhoyTests", dependencies: ["Ahoy"]), 19 | ] 20 | ) 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ahoy iOS 2 | 3 | Simple visit-attribution and analytics library for Apple Platforms for integration with your Rails [Ahoy](http://github.com/ankane/ahoy) backend. 4 | 5 | 🌖 User visit tracking 6 | 7 | 📥 Visit attribution through UTM & referrer parameters 8 | 9 | 📆 Simple, straightforward, in-house event tracking 10 | 11 | [![Actions Status](https://github.com/namolnad/ahoy-ios/workflows/tests/badge.svg)](https://github.com/namolnad/ahoy-ios/actions) 12 | 13 | ## Installation 14 | 15 | The Ahoy library can be easily installed using Swift Package Manager. See the [Apple docs](https://developer.apple.com/documentation/xcode/adding_package_dependencies_to_your_app) for instructions on adding a package to your project. 16 | 17 | ## Usage 18 | 19 | To get started you need to initialize an instance of an Ahoy client. The initializer takes a configuration object, which requires you to provide a `baseUrl` as well as an `ApplicationEnvironment` object. 20 | ``` swift 21 | import Ahoy 22 | 23 | let ahoy: Ahoy = .init( 24 | configuration: .init( 25 | environment: .init( 26 | platform: UIDevice.current.systemName, 27 | appVersion: "1.0.2", 28 | osVersion: UIDevice.current.systemVersion 29 | ), 30 | baseUrl: URL(string: "https://your-server.com")! 31 | ) 32 | ) 33 | ``` 34 | ### Configuration 35 | The configuation object has intelligent defaults _(listed below in parens)_, but allows you to a to provide overrides for a series of values: 36 | - visitDuration _(30 minutes)_ 37 | - urlRequestHandler _(`URLSession.shared.dataTaskPublisher`)_ 38 | - Routing 39 | - ahoyPath _("ahoy")_ 40 | - visitsPath _("visits")_ 41 | - eventsPath _("events")_ 42 | 43 | Beyond configuration, you can also provide your own `AhoyTokenManager` and `RequestInterceptor`s at initialization _(`requestInterceptors` can be modified later)_ for custom token management and pre-flight Ahoy request modifications, respectively. 44 | 45 | ### Tracking a visit 46 | After your client is initialized — ensure you maintain a reference — you'll need to track a visit, typically done at application launch. If desired, you can pass custom data such as utm parameters, referrer, etc. 47 | 48 | ``` swift 49 | ahoy.trackVisit() 50 | .sink(receiveCompletion: { _ in }, receiveOutput: { visit in print(visit) }) 51 | .store(in: &cancellables) 52 | ``` 53 | 54 | ### Tracking events 55 | After your client has successfully registered a visit, you can begin to send events to your server. 56 | ``` swift 57 | /// For bulk-tracking, use the `track(events:)` function 58 | var pendingEvents: [Event] = [] 59 | pendingEvents.append(Event(name: "ride_details.update_driver_rating", properties: ["driver_id": 4])) 60 | pendingEvents.append(Event(name: "ride_details.increase_tip", properties: ["driver_id": 4])) 61 | 62 | ahoy.track(events: pendingEvents) 63 | .sink( 64 | receiveCompletion: { _ in }, // handle error as needed 65 | receiveValue: { pendingEvents.removeAll() } 66 | ) 67 | .store(in: &cancellables) 68 | 69 | /// If you prefer to fire events individually, you can use the fire-and-forget convenience method 70 | ahoy.track("ride_details.update_driver_rating", properties: ["driver_id": 4]) 71 | 72 | /// If your event does not require properties, they can be omitted 73 | ahoy.track("ride_details.update_driver_rating") 74 | ``` 75 | 76 | ### Other goodies 77 | To access the current visit directly, simply use your Ahoy client's `currentVisit` property. _(There is also a currentVisitPublisher you can listen to.)_ Additionally, you can use the `headers` property to add `Ahoy-Visitor` and `Ahoy-Visit` tokens to your own requests as needed. 78 | -------------------------------------------------------------------------------- /Sources/Ahoy/Ahoy.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | public final class Ahoy { 5 | /// The currently registered visit. Nil until a visit has been confirmed with your server 6 | public var currentVisit: Visit? { 7 | currentVisitSubject.value 8 | } 9 | 10 | /// A convenience access point for your application to get Ahoy headers for the current visit 11 | public var headers: [String: String] { 12 | guard let visit = currentVisit else { return [:] } 13 | 14 | return [ 15 | "Ahoy-Visitor": visit.visitorToken, 16 | "Ahoy-Visit": visit.visitToken 17 | ] 18 | } 19 | 20 | /// A publisher to allow your application to listen for changes to the `currentVisit` 21 | public var currentVisitPublisher: AnyPublisher { 22 | currentVisitSubject 23 | .compactMap { $0 } 24 | .eraseToAnyPublisher() 25 | } 26 | 27 | /// Hooks for your application to modify the Ahoy requests prior to performing the request 28 | public var requestInterceptors: [RequestInterceptor] 29 | 30 | private let currentVisitSubject: CurrentValueSubject = .init(nil) 31 | 32 | private let configuration: Configuration 33 | 34 | private let storage: AhoyTokenManager 35 | 36 | private var cancellables: Set = [] 37 | 38 | public init( 39 | configuration: Configuration, 40 | requestInterceptors: [RequestInterceptor] = [], 41 | storage: AhoyTokenManager? = nil 42 | ) { 43 | self.configuration = configuration 44 | self.requestInterceptors = requestInterceptors 45 | if storage == nil, let visitDuration = configuration.visitDuration { 46 | TokenManager.visitDuration = visitDuration 47 | } 48 | self.storage = storage ?? TokenManager() 49 | } 50 | 51 | /// Tracks a visit. A visit *must* be tracked prior to tracking events so a `visitToken` can be associated with the event. 52 | /// Can be called multiple times during a session—a new visit will be created as determined by your `TokenManager`. 53 | public func trackVisit(additionalParams: [String: Encodable]? = nil) -> AnyPublisher { 54 | let visit: Visit = .init( 55 | visitorToken: storage.visitorToken, 56 | visitToken: storage.visitToken, 57 | additionalParams: additionalParams 58 | ) 59 | 60 | let requestInput: VisitRequestInput = .init( 61 | visitorToken: visit.visitorToken, 62 | visitToken: visit.visitToken, 63 | platform: configuration.platform, 64 | appVersion: configuration.appVersion, 65 | osVersion: configuration.osVersion, 66 | additionalParams: additionalParams 67 | ) 68 | 69 | return dataTaskPublisher( 70 | path: configuration.visitsPath, 71 | body: requestInput, 72 | visit: visit 73 | ) 74 | .validateResponse() 75 | .map { $0.0 } 76 | .decode(type: Visit.self, decoder: Self.jsonDecoder) 77 | .tryMap { visitResponse -> Visit in 78 | guard 79 | visit.visitorToken == visitResponse.visitorToken, 80 | visit.visitToken == visitResponse.visitToken 81 | else { 82 | throw AhoyError.mismatchingVisit 83 | } 84 | // Pass back the visit created by the client (which has custom variables) 85 | return visit 86 | } 87 | .handleEvents(receiveOutput: { [weak self] visit in 88 | self?.currentVisitSubject.send(visit) 89 | }) 90 | .eraseToAnyPublisher() 91 | } 92 | 93 | /// Bulk-tracking events 94 | public func track(events: [Event]) -> AnyPublisher { 95 | guard let currentVisit = currentVisit else { 96 | return Fail(error: AhoyError.noVisit) 97 | .eraseToAnyPublisher() 98 | } 99 | 100 | let requestInput: EventRequestInput = .init( 101 | visitorToken: currentVisit.visitorToken, 102 | visitToken: currentVisit.visitToken, 103 | events: events.map { .init(userId: currentVisit.userId, wrapped: $0) } 104 | ) 105 | 106 | return dataTaskPublisher( 107 | path: configuration.eventsPath, 108 | body: requestInput, 109 | visit: currentVisit 110 | ) 111 | .validateResponse() 112 | .map { _, _ in } 113 | .mapError { $0 } 114 | .eraseToAnyPublisher() 115 | } 116 | 117 | /// A fire-and-forget convenience function for tracking a single event 118 | public func track(_ eventName: String, properties: [String: Encodable] = [:]) { 119 | track(events: [.init(name: eventName, properties: properties)]) 120 | .retry(3) 121 | .sink(receiveCompletion: { _ in }, receiveValue: {}) 122 | .store(in: &cancellables) 123 | } 124 | 125 | /// Attaches a User's ID to be encoded as a root key within subsequent event JSON payloads. NOTE: This does not authenticate the user on your server and is only useful for specially-handled cases. 126 | /// Example event payload `{"properties":{"123":456},"user_id":"12345","name":"test","time":"1970-01-01T00:00:00Z"}` 127 | public func attach(userId: String) { 128 | var currentVisit = currentVisitSubject.value 129 | currentVisit?.userId = userId 130 | currentVisitSubject.send(currentVisit) 131 | } 132 | 133 | private func dataTaskPublisher(path: String, body: Body, visit: Visit) -> Configuration.URLRequestPublisher { 134 | var request: URLRequest = .init( 135 | url: configuration.baseUrl 136 | .appendingPathComponent(configuration.ahoyPath) 137 | .appendingPathComponent(path) 138 | ) 139 | request.httpBody = try? Self.jsonEncoder.encode(body) 140 | request.httpMethod = "POST" 141 | 142 | requestInterceptors.forEach { $0.interceptRequest(&request) } 143 | 144 | let ahoyHeaders: [String: String] = [ 145 | "Content-Type": " application/json; charset=utf-8", 146 | "Ahoy-Visitor": visit.visitorToken, 147 | "Ahoy-Visit": visit.visitToken 148 | ] 149 | 150 | ahoyHeaders.forEach { request.setValue($1, forHTTPHeaderField: $0) } 151 | 152 | return configuration.urlRequestHandler(request) 153 | } 154 | 155 | private static let jsonEncoder: JSONEncoder = { 156 | let encoder: JSONEncoder = .init() 157 | encoder.keyEncodingStrategy = .convertToSnakeCase 158 | encoder.dateEncodingStrategy = .iso8601 159 | encoder.outputFormatting = [.sortedKeys] 160 | return encoder 161 | }() 162 | 163 | private static let jsonDecoder: JSONDecoder = { 164 | let decoder: JSONDecoder = .init() 165 | decoder.keyDecodingStrategy = .convertFromSnakeCase 166 | decoder.dateDecodingStrategy = .iso8601 167 | return decoder 168 | }() 169 | 170 | private struct EventRequestInput: Encodable { 171 | var visitorToken: String 172 | var visitToken: String 173 | var events: [UserIdDecorated] 174 | } 175 | 176 | private struct VisitRequestInput: Encodable { 177 | private enum CodingKeys: CodingKey { 178 | case visitorToken 179 | case visitToken 180 | case platform 181 | case appVersion 182 | case osVersion 183 | case custom(String) 184 | 185 | var stringValue: String { 186 | switch self { 187 | case .appVersion: 188 | return "appVersion" 189 | case .osVersion: 190 | return "osVersion" 191 | case .platform: 192 | return "platform" 193 | case .visitToken: 194 | return "visitToken" 195 | case .visitorToken: 196 | return "visitorToken" 197 | case let .custom(value): 198 | return value 199 | } 200 | } 201 | 202 | init?(stringValue: String) { 203 | switch stringValue { 204 | case "appVersion": 205 | self = .appVersion 206 | case "osVersion": 207 | self = .osVersion 208 | case "platform": 209 | self = .platform 210 | case "visitToken": 211 | self = .visitToken 212 | case "visitorToken": 213 | self = .visitorToken 214 | default: 215 | self = .custom(stringValue) 216 | } 217 | } 218 | 219 | var intValue: Int? { nil } 220 | 221 | init?(intValue: Int) { nil } 222 | } 223 | 224 | var visitorToken: String 225 | var visitToken: String 226 | var platform: String 227 | var appVersion: String 228 | var osVersion: String 229 | var additionalParams: [String: Encodable]? 230 | 231 | func encode(to encoder: Encoder) throws { 232 | var container = encoder.container(keyedBy: CodingKeys.self) 233 | try container.encode(visitorToken, forKey: .visitorToken) 234 | try container.encode(visitToken, forKey: .visitToken) 235 | try container.encode(platform, forKey: .platform) 236 | try container.encode(appVersion, forKey: .appVersion) 237 | try container.encode(osVersion, forKey: .osVersion) 238 | try additionalParams?.sorted(by: { $0.0 < $1.0 }).forEach { key, value in 239 | let wrapper = AnyEncodable(wrapped: value) 240 | try container.encode(wrapper, forKey: .custom(key)) 241 | } 242 | } 243 | } 244 | 245 | private struct UserIdDecorated: Encodable { 246 | enum CodingKeys: CodingKey { 247 | case userId 248 | } 249 | 250 | let userId: String? 251 | let wrapped: Wrapped 252 | 253 | func encode(to encoder: Encoder) throws { 254 | var container = encoder.container(keyedBy: CodingKeys.self) 255 | try container.encodeIfPresent(userId, forKey: .userId) 256 | try wrapped.encode(to: encoder) 257 | } 258 | } 259 | } 260 | -------------------------------------------------------------------------------- /Sources/Ahoy/AhoyError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum AhoyError: Error { 4 | /// The server replied with a visit that does not match the one provided by the client 5 | case mismatchingVisit 6 | /// The client has not yet had a visit successfully confirmed with the server 7 | case noVisit 8 | /// The server did not respond with an acceptable status code 9 | case unacceptableResponse(code: Int, data: Data) 10 | } 11 | -------------------------------------------------------------------------------- /Sources/Ahoy/Codable+Ahoy.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Encodable { 4 | func encode(to container: inout SingleValueEncodingContainer) throws { 5 | try container.encode(self) 6 | } 7 | } 8 | 9 | struct AnyEncodable: Encodable { 10 | let wrapped: Encodable 11 | 12 | func encode(to encoder: Encoder) throws { 13 | var container = encoder.singleValueContainer() 14 | try wrapped.encode(to: &container) 15 | } 16 | } 17 | 18 | extension KeyedEncodingContainer { 19 | mutating func encode(_ dictionary: [Key: Encodable], forKey key: K) throws { 20 | try encode(dictionary.mapValues(AnyEncodable.init(wrapped:)), forKey: key) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/Ahoy/Configuration.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | @dynamicMemberLookup 5 | public struct Configuration { 6 | public typealias URLRequestPublisher = AnyPublisher 7 | public typealias URLRequestHandler = (URLRequest) -> URLRequestPublisher 8 | 9 | public struct ApplicationEnvironment { 10 | let platform: String 11 | let appVersion: String 12 | let osVersion: String 13 | 14 | public init(platform: String, appVersion: String, osVersion: String) { 15 | self.platform = platform 16 | self.appVersion = appVersion 17 | self.osVersion = osVersion 18 | } 19 | } 20 | 21 | let environment: ApplicationEnvironment 22 | let urlRequestHandler: URLRequestHandler 23 | let baseUrl: URL 24 | let ahoyPath: String 25 | let eventsPath: String 26 | let visitsPath: String 27 | let visitDuration: TimeInterval? 28 | 29 | public init( 30 | environment: ApplicationEnvironment, 31 | urlRequestHandler: @escaping URLRequestHandler = { request in 32 | URLSession.shared.dataTaskPublisher(for: request).eraseToAnyPublisher() 33 | }, 34 | baseUrl: URL, 35 | ahoyPath: String = "ahoy", 36 | eventsPath: String = "events", 37 | visitsPath: String = "visits", 38 | visitDuration: TimeInterval? = nil 39 | ) { 40 | self.environment = environment 41 | self.urlRequestHandler = urlRequestHandler 42 | self.baseUrl = baseUrl 43 | self.ahoyPath = ahoyPath 44 | self.eventsPath = eventsPath 45 | self.visitsPath = visitsPath 46 | self.visitDuration = visitDuration 47 | } 48 | 49 | subscript(dynamicMember keyPath: KeyPath) -> T { 50 | environment[keyPath: keyPath] 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Sources/Ahoy/Environment.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct Environment { 4 | var date: () -> Date = Date.init 5 | var uuid: () -> UUID = UUID.init 6 | var visitorToken: () -> UUID = visitorTokenProvider 7 | var defaults: UserDefaults = .standard 8 | } 9 | 10 | #if DEBUG 11 | var Current: Environment = .init() 12 | #else 13 | let Current: Environment = .init() 14 | #endif 15 | 16 | #if canImport(WatchKit) 17 | import WatchKit 18 | 19 | let visitorTokenProvider: () -> UUID = { 20 | WKInterfaceDevice.current().identifierForVendor ?? Current.uuid() 21 | } 22 | #elseif canImport(UIKit) 23 | import UIKit 24 | 25 | let visitorTokenProvider: () -> UUID = { 26 | UIDevice.current.identifierForVendor ?? Current.uuid() 27 | } 28 | #elseif canImport(AppKit) 29 | import AppKit 30 | 31 | let visitorTokenProvider: () -> UUID = { 32 | let matchingDict = IOServiceMatching("IOPlatformExpertDevice") 33 | let platformExpert = IOServiceGetMatchingService(kIOMasterPortDefault, matchingDict) 34 | 35 | defer { IOObjectRelease(platformExpert) } 36 | 37 | guard 38 | platformExpert != 0, 39 | let uuidString = IORegistryEntryCreateCFProperty( 40 | platformExpert, 41 | kIOPlatformUUIDKey as CFString, 42 | kCFAllocatorDefault, 0 43 | ).takeRetainedValue() as? String, 44 | let uuid = UUID(uuidString: uuidString) 45 | else { return Current.uuid() } 46 | 47 | return uuid 48 | } 49 | #endif 50 | -------------------------------------------------------------------------------- /Sources/Ahoy/Event.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct Event: Encodable { 4 | enum CodingKeys: CodingKey { 5 | case id 6 | case name 7 | case properties 8 | case time 9 | } 10 | 11 | let id: String? 12 | let name: String 13 | let properties: [String: Encodable] 14 | let time: Date 15 | 16 | public init( 17 | id: String? = nil, 18 | name: String, 19 | properties: [String : Encodable], 20 | time: Date = .init() 21 | ) { 22 | self.id = id 23 | self.name = name 24 | self.properties = properties 25 | self.time = time 26 | } 27 | 28 | public func encode(to encoder: Encoder) throws { 29 | var container = encoder.container(keyedBy: CodingKeys.self) 30 | try container.encodeIfPresent(id, forKey: .id) 31 | try container.encode(name, forKey: .name) 32 | try container.encode(properties, forKey: .properties) 33 | try container.encode(time, forKey: .time) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/Ahoy/ExpiringPersisted.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | @propertyWrapper 4 | struct ExpiringPersisted { 5 | let key: String 6 | let newValue: () -> T 7 | private(set) var expiryPeriod: TimeInterval? = nil 8 | private(set) var defaults: UserDefaults = Current.defaults 9 | let jsonEncoder: JSONEncoder 10 | let jsonDecoder: JSONDecoder 11 | 12 | var wrappedValue: T { 13 | guard 14 | let data = defaults.value(forKey: key) as? Data, 15 | let container = try? jsonDecoder.decode(DatedStorageContainer.self, from: data), 16 | case let now = Current.date(), 17 | (expiryPeriod.map(container.storageDate.advanced(by:)) ?? now) > now 18 | else { 19 | let container: DatedStorageContainer = .init( 20 | storageDate: Current.date(), 21 | value: newValue() 22 | ) 23 | defaults.set(try! jsonEncoder.encode(container), forKey: key) 24 | 25 | return container.value 26 | } 27 | return container.value 28 | } 29 | 30 | private struct DatedStorageContainer: Codable { 31 | let storageDate: Date 32 | let value: T 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/Ahoy/Publisher+Ahoy.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | extension Publisher where Output == (data: Data, response: URLResponse) { 5 | func validateResponse( 6 | acceptableCodes: ClosedRange = 200...399 7 | ) -> AnyPublisher { 8 | tryMap { data, response in 9 | guard let status = (response as? HTTPURLResponse)?.statusCode else { 10 | return (data, response) 11 | } 12 | guard acceptableCodes.contains(status) else { 13 | throw AhoyError.unacceptableResponse(code: status, data: data) 14 | } 15 | return (data, response) 16 | } 17 | .eraseToAnyPublisher() 18 | } 19 | } 20 | 21 | -------------------------------------------------------------------------------- /Sources/Ahoy/RequestInterceptor.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// The interceptor provides an opportunity for your application to peform pre-flight modifications to the Ahoy 4 | /// requests, such as adding custom headers. NOTE: If you set the following headers they will be overwritten by Ahoy 5 | /// prior to performing the request: `Content-Type`, `Ahoy-Visitor`, `Ahoy-Visit`. 6 | public struct RequestInterceptor { 7 | var interceptRequest: (inout URLRequest) -> Void 8 | 9 | public init(interceptRequest: @escaping (inout URLRequest) -> Void) { 10 | self.interceptRequest = interceptRequest 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Sources/Ahoy/TokenManager.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol AhoyTokenManager { 4 | var visitorToken: String { get } 5 | var visitToken: String { get } 6 | } 7 | 8 | final class TokenManager: AhoyTokenManager { 9 | /// Defaults to 30 minutes 10 | static var visitDuration: TimeInterval = .thirtyMinutes 11 | 12 | private static let jsonEncoder: JSONEncoder = .init() 13 | 14 | private static let jsonDecoder: JSONDecoder = .init() 15 | 16 | /// Value will rotate according to the visitDuration 17 | @ExpiringPersisted( 18 | key: "ahoy_visit_token", 19 | newValue: { Current.uuid().uuidString }, 20 | expiryPeriod: TokenManager.visitDuration, 21 | jsonEncoder: jsonEncoder, 22 | jsonDecoder: jsonDecoder 23 | ) 24 | var visitToken: String 25 | 26 | /// Unchanging value 27 | @ExpiringPersisted( 28 | key: "ahoy_visitor_token", 29 | newValue: { Current.visitorToken().uuidString }, 30 | jsonEncoder: jsonEncoder, 31 | jsonDecoder: jsonDecoder 32 | ) 33 | var visitorToken: String 34 | } 35 | 36 | 37 | extension TimeInterval { 38 | fileprivate static let thirtyMinutes: TimeInterval = 1_800 39 | } 40 | -------------------------------------------------------------------------------- /Sources/Ahoy/Visit.swift: -------------------------------------------------------------------------------- 1 | public struct Visit { 2 | public let visitorToken: String 3 | public let visitToken: String 4 | public internal(set) var userId: String? = nil 5 | public internal(set) var additionalParams: [String: Encodable]? = nil 6 | } 7 | 8 | extension Visit: Decodable { 9 | enum CodingKeys: String, CodingKey { 10 | case visitorToken 11 | case visitToken 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Tests/AhoyTests/AhoyTests.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | #if !os(watchOS) 3 | import XCTest 4 | #endif 5 | @testable import Ahoy 6 | 7 | #if !os(watchOS) 8 | @available(iOS 13, tvOS 13, macOS 10.15, *) 9 | final class AhoyTests: XCTestCase { 10 | private var ahoy: Ahoy! 11 | 12 | private let configuration: Configuration = .testDefault 13 | 14 | private var cancellables: Set = [] 15 | 16 | override func setUp() { 17 | super.setUp() 18 | 19 | ahoy = .init(configuration: configuration) 20 | TestDefaults.instance.reset() 21 | Current.defaults = TestDefaults.instance 22 | Current.visitorToken = { UUID(uuidString: "EB4DCB73-2B32-52CD-A2CF-AD7948674B22")! } 23 | } 24 | 25 | func testTrackVisit() { 26 | XCTAssertNil(ahoy.currentVisit) 27 | 28 | Current.date = { .init(timeIntervalSince1970: 0) } 29 | Current.uuid = { UUID(uuidString: "B054681C-100B-46FE-94A0-7AACA78116CB")! } 30 | 31 | let configuration = self.configuration 32 | 33 | var testHeaders: [String: String] = [ 34 | "Ahoy-Visit": "B054681C-100B-46FE-94A0-7AACA78116CB", 35 | "Ahoy-Visitor": "EB4DCB73-2B32-52CD-A2CF-AD7948674B22", 36 | "Content-Type": " application/json; charset=utf-8" 37 | ] 38 | 39 | var expectedRequestBody: String = "{\"app_version\":\"9.9.99\",\"os_version\":\"16.0.2\",\"platform\":\"iOS\",\"visit_token\":\"B054681C-100B-46FE-94A0-7AACA78116CB\",\"visitor_token\":\"EB4DCB73-2B32-52CD-A2CF-AD7948674B22\"}" 40 | 41 | let expectation1 = self.expectation(description: "1") 42 | 43 | ahoy.trackVisit() 44 | .sink( 45 | receiveCompletion: { if case .failure = $0 { XCTFail() } }, 46 | receiveValue: { _ in 47 | XCTAssertEqual(TestRequestHandler.instance.headers, testHeaders) 48 | XCTAssertEqual(TestRequestHandler.instance.body, expectedRequestBody) 49 | XCTAssertEqual(TestRequestHandler.instance.url.absoluteString, "https://ahoy.com/test-ahoy/my-visits") 50 | expectation1.fulfill() 51 | } 52 | ) 53 | .store(in: &cancellables) 54 | 55 | wait(for: [expectation1], timeout: 0.1) 56 | 57 | XCTAssertEqual(ahoy.headers["Ahoy-Visitor"], "EB4DCB73-2B32-52CD-A2CF-AD7948674B22") 58 | 59 | Current.date = { Date(timeIntervalSince1970: 0).advanced(by: configuration.visitDuration! - 1) } 60 | Current.uuid = { UUID(uuidString: "4D02659F-6030-4C9A-B63F-9E322127C42B")! } 61 | 62 | expectedRequestBody = "{\"app_version\":\"9.9.99\",\"os_version\":\"16.0.2\",\"platform\":\"iOS\",\"source\":3,\"utm_source\":\"some-place\",\"visit_token\":\"B054681C-100B-46FE-94A0-7AACA78116CB\",\"visitor_token\":\"EB4DCB73-2B32-52CD-A2CF-AD7948674B22\"}" 63 | 64 | let expectation2 = self.expectation(description: "2") 65 | 66 | ahoy.trackVisit(additionalParams: ["utm_source": "some-place", "source": 3]) 67 | .sink( 68 | receiveCompletion: { if case .failure = $0 { XCTFail() } }, 69 | receiveValue: { _ in 70 | XCTAssertEqual(TestRequestHandler.instance.body, expectedRequestBody) 71 | XCTAssertEqual(TestRequestHandler.instance.headers, testHeaders) 72 | expectation2.fulfill() 73 | } 74 | ) 75 | .store(in: &cancellables) 76 | 77 | wait(for: [expectation2], timeout: 0.1) 78 | 79 | XCTAssertEqual(ahoy.headers["Ahoy-Visitor"], "EB4DCB73-2B32-52CD-A2CF-AD7948674B22") 80 | XCTAssertEqual(ahoy.currentVisit!.visitToken, "B054681C-100B-46FE-94A0-7AACA78116CB") 81 | XCTAssertEqual(ahoy.headers["Ahoy-Visit"], "B054681C-100B-46FE-94A0-7AACA78116CB") 82 | 83 | Current.date = { Date(timeIntervalSince1970: 0).advanced(by: configuration.visitDuration! + 10) } 84 | ahoy.requestInterceptors = [ 85 | .init { $0.addValue("intercepted", forHTTPHeaderField: "intercept-test")} 86 | ] 87 | 88 | testHeaders["Ahoy-Visit"] = "4D02659F-6030-4C9A-B63F-9E322127C42B" 89 | testHeaders["intercept-test"] = "intercepted" 90 | 91 | let expectation3 = self.expectation(description: "3") 92 | 93 | ahoy.trackVisit() 94 | .sink( 95 | receiveCompletion: { if case .failure = $0 { XCTFail() } }, 96 | receiveValue: { _ in 97 | XCTAssertEqual(TestRequestHandler.instance.headers, testHeaders) 98 | expectation3.fulfill() 99 | } 100 | ) 101 | .store(in: &cancellables) 102 | 103 | wait(for: [expectation3], timeout: 0.1) 104 | 105 | XCTAssertEqual(ahoy.currentVisit!.visitToken, "4D02659F-6030-4C9A-B63F-9E322127C42B") 106 | XCTAssertEqual(ahoy.headers["Ahoy-Visit"], "4D02659F-6030-4C9A-B63F-9E322127C42B") 107 | 108 | } 109 | 110 | func testTrackEvents() { 111 | let expectedRequestBody: String = "{\"events\":[{\"name\":\"test\",\"properties\":{\"123\":456},\"time\":\"1970-01-01T00:00:00Z\"}],\"visit_token\":\"98C44594-050F-4DEF-80AF-AB723472469B\",\"visitor_token\":\"EB4DCB73-2B32-52CD-A2CF-AD7948674B22\"}" 112 | 113 | Current.uuid = { UUID(uuidString: "98C44594-050F-4DEF-80AF-AB723472469B")! } 114 | 115 | let visitExpectation = self.expectation(description: "visit") 116 | 117 | ahoy.trackVisit() 118 | .sink(receiveCompletion: { _ in }, receiveValue: { _ in 119 | visitExpectation.fulfill() 120 | }) 121 | .store(in: &cancellables) 122 | 123 | wait(for: [visitExpectation], timeout: 0.1) 124 | 125 | let events: [Event] = [ 126 | .init(name: "test", properties: ["123": 456], time: .init(timeIntervalSince1970: 0)) 127 | ] 128 | 129 | let expectation = self.expectation(description: "track_events") 130 | 131 | ahoy.track(events: events) 132 | .sink( 133 | receiveCompletion: { if case .failure = $0 { XCTFail() } }, 134 | receiveValue: { 135 | XCTAssertEqual(TestRequestHandler.instance.body, expectedRequestBody) 136 | expectation.fulfill() 137 | } 138 | ) 139 | .store(in: &cancellables) 140 | 141 | wait(for: [expectation], timeout: 0.1) 142 | } 143 | 144 | func testTrackEventsWithAttachedUser() { 145 | let expectedRequestBody: String = "{\"events\":[{\"name\":\"test\",\"properties\":{\"123\":456},\"time\":\"1970-01-01T00:00:00Z\",\"user_id\":\"12345\"}],\"visit_token\":\"98C44594-050F-4DEF-80AF-AB723472469B\",\"visitor_token\":\"EB4DCB73-2B32-52CD-A2CF-AD7948674B22\"}" 146 | 147 | Current.uuid = { UUID(uuidString: "98C44594-050F-4DEF-80AF-AB723472469B")! } 148 | 149 | let visitExpectation = self.expectation(description: "visit") 150 | 151 | ahoy.trackVisit() 152 | .sink(receiveCompletion: { _ in }, receiveValue: { _ in 153 | visitExpectation.fulfill() 154 | }) 155 | .store(in: &cancellables) 156 | 157 | wait(for: [visitExpectation], timeout: 0.1) 158 | 159 | ahoy.attach(userId: "12345") 160 | 161 | let events: [Event] = [ 162 | .init(name: "test", properties: ["123": 456], time: .init(timeIntervalSince1970: 0)) 163 | ] 164 | 165 | let expectation = self.expectation(description: "track_events") 166 | 167 | ahoy.track(events: events) 168 | .sink( 169 | receiveCompletion: { if case .failure = $0 { XCTFail() } }, 170 | receiveValue: { 171 | XCTAssertEqual(TestRequestHandler.instance.body, expectedRequestBody) 172 | expectation.fulfill() 173 | } 174 | ) 175 | .store(in: &cancellables) 176 | 177 | wait(for: [expectation], timeout: 0.1) 178 | } 179 | 180 | static var allTests = [ 181 | ("testTrackVisit", testTrackVisit), 182 | ("testTrackEvents", testTrackEvents) 183 | ] 184 | } 185 | 186 | extension Configuration { 187 | static let testDefault: Self = .init( 188 | environment: .testDefault, 189 | urlRequestHandler: TestRequestHandler.instance.publisher(for:), 190 | baseUrl: URL(string: "https://ahoy.com")!, 191 | ahoyPath: "test-ahoy", 192 | eventsPath: "my-events", 193 | visitsPath: "my-visits", 194 | visitDuration: .oneHour 195 | ) 196 | } 197 | 198 | extension Configuration.ApplicationEnvironment { 199 | static let testDefault: Self = .init( 200 | platform: "iOS", 201 | appVersion: "9.9.99", 202 | osVersion: "16.0.2" 203 | ) 204 | } 205 | 206 | extension Optional where Wrapped == TimeInterval { 207 | static let oneHour: TimeInterval = 3_600 208 | } 209 | 210 | final class TestRequestHandler { 211 | var headers: [String: String]! 212 | var body: String! 213 | var url: URL! 214 | 215 | func publisher(for urlRequest: URLRequest) -> Configuration.URLRequestPublisher { 216 | headers = urlRequest.allHTTPHeaderFields 217 | body = String(data: urlRequest.httpBody!, encoding: .utf8) 218 | url = urlRequest.url 219 | 220 | return Just( 221 | ( 222 | urlRequest.httpBody!, 223 | HTTPURLResponse(url: url, statusCode: 200, httpVersion: "1.0", headerFields: headers)! 224 | ) 225 | ) 226 | .setFailureType(to: URLError.self) 227 | .eraseToAnyPublisher() 228 | } 229 | } 230 | 231 | extension TestRequestHandler { 232 | static let instance: TestRequestHandler = .init() 233 | } 234 | 235 | final class TestDefaults: UserDefaults { 236 | static let instance: TestDefaults = .init() 237 | 238 | private var storage: [String: Any] = [:] 239 | 240 | override func set(_ value: Any?, forKey key: String) { 241 | storage[key] = value 242 | } 243 | 244 | override func value(forKey key: String) -> Any? { 245 | storage[key] 246 | } 247 | 248 | func reset() { 249 | storage = [:] 250 | } 251 | } 252 | #endif 253 | -------------------------------------------------------------------------------- /Tests/AhoyTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | #if !os(watchOS) 2 | import XCTest 3 | #endif 4 | 5 | #if !canImport(ObjectiveC) 6 | @available(iOS 13, tvOS 13, macOS 10.15, *) 7 | public func allTests() -> [XCTestCaseEntry] { 8 | return [ 9 | testCase(AhoyTests.allTests), 10 | ] 11 | } 12 | #endif 13 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import AhoyTests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += AhoyTests.allTests() 7 | XCTMain(tests) 8 | --------------------------------------------------------------------------------