Repository: TootSDK/TootSDK
Pull request #167 (2023-05-19 10:58:32)

Improve compatibility for web push notifications and pagination
* [all platforms] Adds the `reblog` alert from the push notification request.
* [all platforms] Removes the identifier from `PushSubscription` (which is unused for API calls) as it requires special handling because it can be a `String` (Pleroma/Akkoma) or an `Int` (Mastodon) depending on server type.
* [all platforms] Enhances pagination to tolerate spaces and line breaks before or after pagination header values (Pleroma/Akkoma seems to produce them sometimes when paginating notifications).
* [Linux] Pagination link parsing now uses [WebURL](https://github.com/karwa/swift-url) to offer consistent validation across platforms. (cf. [see here](https://github.com/TootSDK/TootSDK/pull/167#issuecomment-1554916164))


Patch:
diff --git a/Sources/TootSDK/HTTP/Pagination.swift b/Sources/TootSDK/HTTP/Pagination.swift
index 17e5921..6675ea1 100644
--- a/Sources/TootSDK/HTTP/Pagination.swift
+++ b/Sources/TootSDK/HTTP/Pagination.swift
@@ -2,6 +2,7 @@
 // Copyright (c) 2022. All rights reserved.
 
 import Foundation
+import WebURL
 
 public struct Pagination {
     public var maxId: String?
@@ -13,10 +14,13 @@ public extension Pagination {
     static let paginationTypes: [String] = ["prev", "next"]
 
     init(links: String) {
-        let links = links.components(separatedBy: ",")
+        let links = links.components(separatedBy: ",").map({
+            $0.trimmingCharacters(in: .whitespacesAndNewlines)
+        })
 
         let paginationQueryItems: [URLQueryItem] = links.compactMap({ link in
-            let segments = link
+            let segments =
+                link
                 .condensed()
                 .components(separatedBy: ";")
             let url = segments.first.map(trim(left: "<", right: ">"))
@@ -25,18 +29,37 @@ public extension Pagination {
                 .trimmingCharacters(in: .whitespaces)
                 .components(separatedBy: "=")
 
-            guard
-                let validURL = url,
-                let referenceKey = rel?.first, referenceKey == "rel",
-                let referenceValue = rel?.last,
-                Self.paginationTypes.contains(referenceValue),
-                let queryItems = URLComponents(string: validURL)?.queryItems
-            else {
-                print("TootSDK: invalid pagination Link '\(link)'")
+            guard let validURL = url else {
+                print("TootSDK: invalid pagination Link (url): '\(link)'")
                 return []
             }
 
-            return queryItems
+            guard let referenceKey = rel?.first else {
+                print("TootSDK: invalid pagination Link (referenceKey): '\(link)'")
+                return []
+            }
+
+            guard let referenceValue = rel?.last else {
+                print("TootSDK: invalid pagination Link (referenceValue): '\(link)'")
+                return []
+            }
+
+            guard referenceKey == "rel" else {
+                print("TootSDK: invalid pagination Link (rel): '\(link)'")
+                return []
+            }
+
+            guard Self.paginationTypes.contains(referenceValue) else {
+                print("TootSDK: invalid pagination Link (paginationType): '\(link)'")
+                return []
+            }
+
+            guard let webURL = WebURL(validURL) else {
+                print("TootSDK: invalid pagination Link (query): '\(link)'")
+                return []
+            }
+
+            return webURL.formParams.allKeyValuePairs.map({URLQueryItem(name: $0.0, value: $0.1)})
         }).reduce([], +)
 
         minId = paginationQueryItems.first { $0.name == "min_id" }?.value
@@ -48,6 +71,7 @@ public extension Pagination {
 func trim(left: Character, right: Character) -> (String) -> String {
     return { string in
         guard string.hasPrefix("\(left)"), string.hasSuffix("\(right)") else { return string }
-        return String(string[string.index(after: string.startIndex)..<string.index(before: string.endIndex)])
+        return String(
+            string[string.index(after: string.startIndex)..<string.index(before: string.endIndex)])
     }
 }
diff --git a/Sources/TootSDK/Models/PushSubscription.swift b/Sources/TootSDK/Models/PushSubscription.swift
index 21c0ee5..3fb320e 100644
--- a/Sources/TootSDK/Models/PushSubscription.swift
+++ b/Sources/TootSDK/Models/PushSubscription.swift
@@ -4,16 +4,13 @@
 import Foundation
 
 /// Represents a subscription to the push streaming server.
-public struct PushSubscription: Codable, Identifiable, Sendable {
-    public init(id: String, endpoint: String, alerts: Alerts, serverKey: String) {
-        self.id = id
+public struct PushSubscription: Codable, Sendable {
+    public init(endpoint: String, alerts: Alerts, serverKey: String) {
         self.endpoint = endpoint
         self.alerts = alerts
         self.serverKey = serverKey
     }
 
-    /// The id of the push subscription in the database.
-    public var id: String
     /// Where push alerts will be sent to.
     public var endpoint: String
     /// Which alerts should be delivered to the endpoint.
diff --git a/Sources/TootSDK/TootClient/TootClient+Notifications.swift b/Sources/TootSDK/TootClient/TootClient+Notifications.swift
index e717d2a..ffcaa99 100644
--- a/Sources/TootSDK/TootClient/TootClient+Notifications.swift
+++ b/Sources/TootSDK/TootClient/TootClient+Notifications.swift
@@ -53,6 +53,7 @@ public extension TootClient {
     /// Add a Web Push API subscription to receive notifications. Each access token can have one push subscription.
     ///
     /// If you create a new subscription, the old subscription is deleted.
+    @discardableResult
     func createPushSubscription(params: PushSubscriptionParams) async throws -> PushSubscription {
         let req = try HTTPRequestBuilder {
             $0.url = getURL(["api", "v1", "push", "subscription"])
@@ -114,6 +115,10 @@ public extension TootClient {
             queryParameters.append(.init(name: "data[alerts][status]", value: String(alert).lowercased()))
         }
 
+        if let alert = params.data?.alerts?.repost {
+            queryParameters.append(.init(name: "data[alerts][reblog]", value: String(alert).lowercased()))
+        }
+
         if let alert = params.data?.alerts?.follow {
             queryParameters.append(.init(name: "data[alerts][follow]", value: String(alert).lowercased()))
         }


Test patch:
diff --git a/Tests/TootSDKTests/PaginationTests.swift b/Tests/TootSDKTests/PaginationTests.swift
index c4178da..64de1c5 100644
--- a/Tests/TootSDKTests/PaginationTests.swift
+++ b/Tests/TootSDKTests/PaginationTests.swift
@@ -2,6 +2,7 @@
 // Copyright (c) 2022. All rights reserved.
 
 import XCTest
+
 @testable import TootSDK
 
 final class PaginationTests: XCTestCase {
@@ -10,7 +11,7 @@ final class PaginationTests: XCTestCase {
     func testPaginationWithInvalidNextAndPrevious() {
         let links = [
             "<\(serverUrl)/api/v1/timelines/home?max_id=420>; rel=\"\"",
-            "this is not a valid URL; rel=\"next\""
+            "this is not a valid URL; rel=\"next\"",
         ].joined(separator: ",")
         
         let pagination = Pagination(links: links)
@@ -23,7 +24,7 @@ final class PaginationTests: XCTestCase {
     func testPaginationWithValidNext() {
         let links = [
             "<\(serverUrl)/api/v1/timelines/home?limit=42&max_id=420>; rel=\"next\"",
-            "this is not a valid URL; rel=\"prev\""
+            "this is not a valid URL; rel=\"prev\"",
         ].joined(separator: ",")
         
         let pagination = Pagination(links: links)
@@ -36,7 +37,7 @@ final class PaginationTests: XCTestCase {
     func testPaginationWithValidPrevious() {
         let links = [
             "<\(serverUrl)/api/v1/timelines/home?limit=42&since_id=420>; rel=\"prev\"",
-            "this is not a valid URL; rel=\"next\""
+            "this is not a valid URL; rel=\"next\"",
         ].joined(separator: ",")
         
         let pagination = Pagination(links: links)
@@ -49,7 +50,7 @@ final class PaginationTests: XCTestCase {
     func testPaginationWithValidNextAndPrevious() {
         let links = [
             "<\(serverUrl)/api/v1/timelines/home?limit=42&since_id=123>; rel=\"prev\"",
-            "<\(serverUrl)/api/v1/timelines/home?limit=52&max_id=321>; rel=\"next\""
+            "<\(serverUrl)/api/v1/timelines/home?limit=52&max_id=321>; rel=\"next\"",
         ].joined(separator: ",")
         
         let pagination = Pagination(links: links)
@@ -58,4 +59,16 @@ final class PaginationTests: XCTestCase {
         XCTAssertNil(pagination.minId)
         XCTAssertEqual(pagination.maxId, "321")
     }
+    
+    func testPaginationTolleratesSpacesAndNewLines() {
+        let links: String = [
+            "\n <https://m.iamkonstantin.eu/api/v1/notifications?limit=20&max_id=15223&offset=0&types[]=mention>; rel=\"next\"\n "
+        ].joined(separator: ",")
+        
+        let pagination = Pagination(links: links)
+        
+        XCTAssertNil(pagination.sinceId)
+        XCTAssertNil(pagination.minId)
+        XCTAssertEqual(pagination.maxId, "15223")
+    }
 }