From b18226990861cbc3f6976f332b5c96aff15e0b34 Mon Sep 17 00:00:00 2001 From: GErP83 Date: Tue, 18 Aug 2026 18:20:44 +0200 Subject: [PATCH 1/2] add TopicPushClient --- README.md | 37 +++++++++++++++---- Sources/FeatherPush/Client/PushClient.swift | 26 +++++++++++++ .../FeatherPush/Client/PushClientError.swift | 31 ++++++++++++++++ .../FeatherPush/Client/TopicPushClient.swift | 26 +++++++++++++ Sources/FeatherPush/Models/Delivery.swift | 6 +-- .../FeatherPush/Models/PushNotification.swift | 30 +++++++++++---- Sources/FeatherPush/Models/Sound.swift | 4 +- Sources/FeatherPush/PushClient.swift | 21 ----------- Sources/FeatherPush/PushClientError.swift | 25 ------------- Tests/FeatherPushTests/FeatherPushTests.swift | 4 +- 10 files changed, 143 insertions(+), 67 deletions(-) create mode 100644 Sources/FeatherPush/Client/PushClient.swift create mode 100644 Sources/FeatherPush/Client/PushClientError.swift create mode 100644 Sources/FeatherPush/Client/TopicPushClient.swift delete mode 100644 Sources/FeatherPush/PushClient.swift delete mode 100644 Sources/FeatherPush/PushClientError.swift diff --git a/README.md b/README.md index 26467d9..8274493 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ An abstract push notification client for Feather CMS. -[![Release: 1.0.0-beta.1](https://img.shields.io/badge/Release-1%2E0%2E0--beta%2E1-F05138)](https://github.com/feather-framework/feather-push/releases/tag/1.0.0-beta.1) +[![Release: 1.0.0-beta.2](https://img.shields.io/badge/Release-1%2E0%2E0--beta%2E2-F05138)](https://github.com/feather-framework/feather-push/releases/tag/1.0.0-beta.2) ## Features -- Topic-based push notification delivery +- Topic- and device-token-based push notification delivery - Silent and normal delivery modes - Deep-link and rich notification metadata - Badge, sound, and notification collapsing support @@ -31,7 +31,7 @@ An abstract push notification client for Feather CMS. Use Swift Package Manager; add the dependency to your `Package.swift` file: ```swift -.package(url: "https://github.com/feather-framework/feather-push", exact: "1.0.0-beta.1"), +.package(url: "https://github.com/feather-framework/feather-push", exact: "1.0.0-beta.2"), ``` Then add `FeatherPush` to your target dependencies: @@ -46,6 +46,14 @@ Then add `FeatherPush` to your target dependencies: API documentation is available at the following link. +`PushClient` provides device-token delivery. Providers that support topic +delivery additionally conform to `TopicPushClient`. + +The capabilities are separated because push providers do not all support the +same targeting model. For example, FCM supports subscribable topics, while +APNs delivers to device tokens and uses its topic value to identify the +application rather than a group of subscribers. + ```swift let notification = PushNotification( title: "New message", @@ -56,10 +64,25 @@ let notification = PushNotification( sound: .default ) -try await client.send( - notification: notification, - to: "topic" -) +// Works with every PushClient implementation. +func sendToDevice(using client: some PushClient) async throws { + try await client.sendToDevice( + notification: notification, + deviceToken: "device-registration-token" + ) +} +``` + +Topic-capable clients can also send to provider-managed topics: + +```swift +// Requires a TopicPushClient implementation, such as FCM. +func sendToTopic(using client: some TopicPushClient) async throws { + try await client.sendToTopic( + notification: notification, + topic: "messages" + ) +} ``` > [!WARNING] diff --git a/Sources/FeatherPush/Client/PushClient.swift b/Sources/FeatherPush/Client/PushClient.swift new file mode 100644 index 0000000..f877992 --- /dev/null +++ b/Sources/FeatherPush/Client/PushClient.swift @@ -0,0 +1,26 @@ +// +// PushClient.swift +// feather-push +// +// Created by Binary Birds on 2026. 08. 14. +// + +/// A provider-neutral client capable of delivering notifications to devices. +/// +/// Providers identify devices differently. The `deviceToken` value must be +/// supplied in the format expected by the provider implementation. +/// +/// Implementations may support additional delivery capabilities through more +/// specialized protocols, such as ``TopicPushClient``. +public protocol PushClient: Sendable { + /// Sends a notification to one device. + /// + /// - Parameters: + /// - notification: The notification content and delivery options. + /// - deviceToken: The provider-issued token identifying the device. + /// - Throws: A ``PushClientError`` when the notification cannot be sent. + func sendToDevice( + notification: PushNotification, + deviceToken: String + ) async throws(PushClientError) +} diff --git a/Sources/FeatherPush/Client/PushClientError.swift b/Sources/FeatherPush/Client/PushClientError.swift new file mode 100644 index 0000000..3a6254d --- /dev/null +++ b/Sources/FeatherPush/Client/PushClientError.swift @@ -0,0 +1,31 @@ +// +// PushClientError.swift +// feather-push +// +// Created by Binary Birds on 2026. 08. 14. +// + +/// Provider-neutral errors that can occur while delivering a push notification. +public enum PushClientError: Error { + + /// The topic is empty or is not valid for the provider. + case invalidTopic + /// The device token is empty or is not valid for the provider. + case invalidDeviceToken + /// The notification payload is invalid or contains unsupported values. + case invalidNotification + /// The provider credentials are invalid or the request is unauthorized. + case unauthorized + /// The provider temporarily rejected the request because it was rate limited. + case rateLimited + /// The provider is temporarily unavailable; retrying may succeed later. + case unavailable + /// The provider rejected the request with a descriptive reason. + /// + /// - Parameter reason: The provider-supplied rejection message. + case rejected(String) + /// An underlying error without a more specific provider-neutral mapping. + /// + /// - Parameter error: The underlying provider or transport error. + case unknown(Error) +} diff --git a/Sources/FeatherPush/Client/TopicPushClient.swift b/Sources/FeatherPush/Client/TopicPushClient.swift new file mode 100644 index 0000000..f2c2e5a --- /dev/null +++ b/Sources/FeatherPush/Client/TopicPushClient.swift @@ -0,0 +1,26 @@ +// +// TopicPushClient.swift +// feather-push +// +// Created by Binary Birds on 2026. 08. 18. +// + +/// A push client that supports provider-managed topic delivery. +/// +/// Topic semantics are provider-specific. For example, an FCM topic can target +/// subscribed devices, while an APNs topic identifies an application and does +/// not replace a device token. +public protocol TopicPushClient: PushClient { + + /// Sends a notification to all devices subscribed to the topic. + /// + /// - Parameters: + /// - notification: The notification content and delivery options. + /// - topic: The provider-managed topic name. + /// - Throws: A ``PushClientError`` when the topic or notification is invalid + /// or the provider rejects the request. + func sendToTopic( + notification: PushNotification, + topic: String + ) async throws(PushClientError) +} diff --git a/Sources/FeatherPush/Models/Delivery.swift b/Sources/FeatherPush/Models/Delivery.swift index f1527b4..379373b 100644 --- a/Sources/FeatherPush/Models/Delivery.swift +++ b/Sources/FeatherPush/Models/Delivery.swift @@ -5,10 +5,10 @@ // Created by Binary Birds on 2026. 08. 13. // -/// The delivery priority of a push notification. +/// The requested presentation mode for a push notification. public enum Delivery: String, Sendable { - /// Delivers the notification normally. + /// Delivers a notification that may be presented to the user. case normal - /// Delivers the notification without presenting an alert. + /// Delivers notification data without requesting a visible alert. case silent } diff --git a/Sources/FeatherPush/Models/PushNotification.swift b/Sources/FeatherPush/Models/PushNotification.swift index 2f23bb2..86ab96c 100644 --- a/Sources/FeatherPush/Models/PushNotification.swift +++ b/Sources/FeatherPush/Models/PushNotification.swift @@ -5,28 +5,42 @@ // Created by Binary Birds on 2026. 08. 13. // -/// A push notification payload. +/// The provider-neutral content and delivery options for a push notification. public struct PushNotification: Sendable { /// The notification title. public let title: String /// The notification body. public let body: String - /// Provider-specific notification metadata. + /// Application-defined key-value data delivered with the notification. + /// + /// Providers may impose restrictions on keys and values. Reserved provider + /// keys may be rejected by a concrete client. public let data: [String: String] - /// The requested delivery priority. + /// Whether the notification should be presented or delivered silently. public let delivery: Delivery - /// An optional deep link opened when the notification is selected. + /// An optional deep link for the application to open when selected. public let deepLink: String? - /// An optional image URL supported by providers that render rich notifications. + /// An optional image URL for providers that support rich notifications. public let imageURL: String? /// An optional badge value for the application icon. public let badge: Int? - /// An optional notification sound. + /// An optional sound for providers and platforms that support notification sounds. public let sound: Sound? - /// An optional identifier used to collapse equivalent notifications. + /// An optional provider-specific identifier used to collapse equivalent notifications. public let collapseID: String? - /// Creates a push notification. + /// Creates a provider-neutral push notification. + /// + /// - Parameters: + /// - title: The notification title. + /// - body: The notification body. + /// - data: Application-defined data delivered with the notification. + /// - delivery: The requested presentation mode. + /// - deepLink: An optional application deep link. + /// - imageURL: An optional image URL for rich notifications. + /// - badge: An optional application badge value. + /// - sound: An optional notification sound. + /// - collapseID: An optional identifier for collapsing equivalent notifications. public init( title: String, body: String, diff --git a/Sources/FeatherPush/Models/Sound.swift b/Sources/FeatherPush/Models/Sound.swift index 98d78db..a32d3bf 100644 --- a/Sources/FeatherPush/Models/Sound.swift +++ b/Sources/FeatherPush/Models/Sound.swift @@ -5,10 +5,10 @@ // Created by Binary Birds on 2026. 08. 13. // -/// The sound to play when a notification is presented. +/// A notification sound requested when a provider presents the notification. public enum Sound: Sendable, Equatable { /// Use the platform default notification sound. case `default` - /// Use a bundled, provider-specific sound name. + /// Use a sound bundled with the target application. case named(String) } diff --git a/Sources/FeatherPush/PushClient.swift b/Sources/FeatherPush/PushClient.swift deleted file mode 100644 index 2d4bc70..0000000 --- a/Sources/FeatherPush/PushClient.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// PushClient.swift -// feather-push -// -// Created by Tibor Bodecs on 2023. 01. 16. -// - -/// A client capable of delivering push notifications. -public protocol PushClient: Sendable { - - /// Sends a push notification to the given recipients. - /// - /// - Parameters: - /// - notification: The notification to deliver. - /// - topic: The topic that receives the notification. - /// - Throws: `PushClientError` when delivery fails. - func send( - notification: PushNotification, - to topic: String - ) async throws(PushClientError) -} diff --git a/Sources/FeatherPush/PushClientError.swift b/Sources/FeatherPush/PushClientError.swift deleted file mode 100644 index 9c4b298..0000000 --- a/Sources/FeatherPush/PushClientError.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// PushClientError.swift -// feather-push -// -// Created by Tibor Bodecs on 2023. 01. 16. -// - -/// Errors that can occur while delivering a push notification. -public enum PushClientError: Error { - - /// The topic is empty or otherwise invalid. - case invalidTopic - /// The notification payload is invalid or cannot be delivered. - case invalidNotification - /// The provider credentials or authorization are invalid. - case unauthorized - /// The provider temporarily rejected the request because it was rate-limited. - case rateLimited - /// The provider is temporarily unavailable. - case unavailable - /// The provider rejected the request with a descriptive reason. - case rejected(String) - /// An underlying provider error that does not have a more specific mapping. - case unknown(Error) -} diff --git a/Tests/FeatherPushTests/FeatherPushTests.swift b/Tests/FeatherPushTests/FeatherPushTests.swift index 01b88d3..c80d827 100644 --- a/Tests/FeatherPushTests/FeatherPushTests.swift +++ b/Tests/FeatherPushTests/FeatherPushTests.swift @@ -47,6 +47,7 @@ struct FeatherPushTests { func clientErrorsExposeProviderNeutralCases() { let errors: [PushClientError] = [ .invalidTopic, + .invalidDeviceToken, .invalidNotification, .unauthorized, .rateLimited, @@ -54,6 +55,7 @@ struct FeatherPushTests { .rejected("provider rejected the request"), ] - #expect(errors.count == 6) + #expect(errors.count == 7) } + } From 795572210007a352115e8cea3451ac0dab25d222 Mon Sep 17 00:00:00 2001 From: GErP83 Date: Wed, 19 Aug 2026 08:21:43 +0200 Subject: [PATCH 2/2] add PushDeliveryTarget --- README.md | 29 +++++++++---------- Sources/FeatherPush/Client/PushClient.swift | 18 +++++------- .../FeatherPush/Client/PushClientError.swift | 2 ++ .../Client/PushDeliveryTarget.swift | 18 ++++++++++++ .../FeatherPush/Client/TopicPushClient.swift | 26 ----------------- Tests/FeatherPushTests/FeatherPushTests.swift | 3 +- 6 files changed, 44 insertions(+), 52 deletions(-) create mode 100644 Sources/FeatherPush/Client/PushDeliveryTarget.swift delete mode 100644 Sources/FeatherPush/Client/TopicPushClient.swift diff --git a/README.md b/README.md index 8274493..a0594d9 100644 --- a/README.md +++ b/README.md @@ -46,13 +46,13 @@ Then add `FeatherPush` to your target dependencies: API documentation is available at the following link. -`PushClient` provides device-token delivery. Providers that support topic -delivery additionally conform to `TopicPushClient`. +`PushClient` accepts a single delivery target for every notification. -The capabilities are separated because push providers do not all support the -same targeting model. For example, FCM supports subscribable topics, while -APNs delivers to device tokens and uses its topic value to identify the -application rather than a group of subscribers. +Providers do not all support the same targeting model. For example, FCM +supports subscribable topics, while APNs delivers to device tokens and uses +its topic value to identify the application rather than a group of subscribers. +An implementation that cannot handle a target should throw +`PushClientError.unsupportedTarget`. ```swift let notification = PushNotification( @@ -64,23 +64,22 @@ let notification = PushNotification( sound: .default ) -// Works with every PushClient implementation. -func sendToDevice(using client: some PushClient) async throws { - try await client.sendToDevice( +// Device-token delivery is supported by every PushClient implementation. +func sendDeviceNotification(using client: some PushClient) async throws { + try await client.send( notification: notification, - deviceToken: "device-registration-token" + to: .deviceToken("device-registration-token") ) } ``` -Topic-capable clients can also send to provider-managed topics: +The same interface can target a provider-managed topic: ```swift -// Requires a TopicPushClient implementation, such as FCM. -func sendToTopic(using client: some TopicPushClient) async throws { - try await client.sendToTopic( +func sendTopicNotification(using client: some PushClient) async throws { + try await client.send( notification: notification, - topic: "messages" + to: .topic("messages") ) } ``` diff --git a/Sources/FeatherPush/Client/PushClient.swift b/Sources/FeatherPush/Client/PushClient.swift index f877992..cfe7d9f 100644 --- a/Sources/FeatherPush/Client/PushClient.swift +++ b/Sources/FeatherPush/Client/PushClient.swift @@ -5,22 +5,20 @@ // Created by Binary Birds on 2026. 08. 14. // -/// A provider-neutral client capable of delivering notifications to devices. +/// A provider-neutral client capable of delivering notifications to supported targets. /// -/// Providers identify devices differently. The `deviceToken` value must be -/// supplied in the format expected by the provider implementation. -/// -/// Implementations may support additional delivery capabilities through more -/// specialized protocols, such as ``TopicPushClient``. +/// The target determines how the provider should route the notification. A +/// provider that does not support a target should throw +/// ``PushClientError/unsupportedTarget``. public protocol PushClient: Sendable { - /// Sends a notification to one device. + /// Sends a notification to the specified delivery target. /// /// - Parameters: /// - notification: The notification content and delivery options. - /// - deviceToken: The provider-issued token identifying the device. + /// - target: A device token or provider-managed topic. /// - Throws: A ``PushClientError`` when the notification cannot be sent. - func sendToDevice( + func send( notification: PushNotification, - deviceToken: String + to target: PushDeliveryTarget ) async throws(PushClientError) } diff --git a/Sources/FeatherPush/Client/PushClientError.swift b/Sources/FeatherPush/Client/PushClientError.swift index 3a6254d..cdd6be5 100644 --- a/Sources/FeatherPush/Client/PushClientError.swift +++ b/Sources/FeatherPush/Client/PushClientError.swift @@ -14,6 +14,8 @@ public enum PushClientError: Error { case invalidDeviceToken /// The notification payload is invalid or contains unsupported values. case invalidNotification + /// The provider does not support the requested target type. + case unsupportedTarget /// The provider credentials are invalid or the request is unauthorized. case unauthorized /// The provider temporarily rejected the request because it was rate limited. diff --git a/Sources/FeatherPush/Client/PushDeliveryTarget.swift b/Sources/FeatherPush/Client/PushDeliveryTarget.swift new file mode 100644 index 0000000..9bc8b59 --- /dev/null +++ b/Sources/FeatherPush/Client/PushDeliveryTarget.swift @@ -0,0 +1,18 @@ +// +// PushDeliveryTarget.swift +// feather-push +// +// Created by Binary Birds on 2026. 08. 19. +// + +/// A destination used when sending a push notification. +/// +/// The meaning and availability of each target is provider-specific. A topic +/// may represent a group of subscribed devices for one provider, while another +/// provider may use its topic value only to identify an application. +public enum PushDeliveryTarget: Sendable, Equatable { + /// A provider-issued token identifying a single device. + case deviceToken(String) + /// A provider-managed topic identifying a destination group or service. + case topic(String) +} diff --git a/Sources/FeatherPush/Client/TopicPushClient.swift b/Sources/FeatherPush/Client/TopicPushClient.swift deleted file mode 100644 index f2c2e5a..0000000 --- a/Sources/FeatherPush/Client/TopicPushClient.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// TopicPushClient.swift -// feather-push -// -// Created by Binary Birds on 2026. 08. 18. -// - -/// A push client that supports provider-managed topic delivery. -/// -/// Topic semantics are provider-specific. For example, an FCM topic can target -/// subscribed devices, while an APNs topic identifies an application and does -/// not replace a device token. -public protocol TopicPushClient: PushClient { - - /// Sends a notification to all devices subscribed to the topic. - /// - /// - Parameters: - /// - notification: The notification content and delivery options. - /// - topic: The provider-managed topic name. - /// - Throws: A ``PushClientError`` when the topic or notification is invalid - /// or the provider rejects the request. - func sendToTopic( - notification: PushNotification, - topic: String - ) async throws(PushClientError) -} diff --git a/Tests/FeatherPushTests/FeatherPushTests.swift b/Tests/FeatherPushTests/FeatherPushTests.swift index c80d827..55aa515 100644 --- a/Tests/FeatherPushTests/FeatherPushTests.swift +++ b/Tests/FeatherPushTests/FeatherPushTests.swift @@ -49,13 +49,14 @@ struct FeatherPushTests { .invalidTopic, .invalidDeviceToken, .invalidNotification, + .unsupportedTarget, .unauthorized, .rateLimited, .unavailable, .rejected("provider rejected the request"), ] - #expect(errors.count == 7) + #expect(errors.count == 8) } }