├── .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 | [](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