diff --git a/README.md b/README.md
index b365c04..7d4024f 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# WebPush
+# Swift WebPush
@@ -16,19 +16,19 @@ A server-side Swift implementation of the WebPush standard.
## Quick Links
-- [Documentation](https://swiftpackageindex.com/mochidev/swift-WebPush/documentation)
+- [Documentation](https://swiftpackageindex.com/mochidev/swift-webpush/documentation)
- [Updates on Mastodon](https://mastodon.social/tags/SwiftWebPush)
## Installation
Add `WebPush` as a dependency in your `Package.swift` file to start using it. Then, add `import WebPush` to any file you wish to use the library in.
-Please check the [releases](https://github.com/mochidev/WebPush/releases) for recommended versions.
+Please check the [releases](https://github.com/mochidev/swift-webpush/releases) for recommended versions.
```swift
dependencies: [
.package(
- url: "https://github.com/mochidev/WebPush.git",
+ url: "https://github.com/mochidev/swift-webPush.git",
.upToNextMinor(from: "0.1.1")
),
],
@@ -39,10 +39,13 @@ targets: [
dependencies: [
"WebPush",
]
- )
- .testTarget(name: "MyPackageTests", dependencies: [
- "WebPushTesting",
- ]
+ ),
+ .testTarget(
+ name: "MyPackageTests",
+ dependencies: [
+ "WebPushTesting",
+ ]
+ ),
]
```
diff --git a/Sources/WebPush/Errors/BadSubscriberError.swift b/Sources/WebPush/Errors/BadSubscriberError.swift
new file mode 100644
index 0000000..8751e1a
--- /dev/null
+++ b/Sources/WebPush/Errors/BadSubscriberError.swift
@@ -0,0 +1,20 @@
+//
+// BadSubscriberError.swift
+// swift-webpush
+//
+// Created by Dimitri Bouniol on 2024-12-13.
+// Copyright © 2024 Mochi Development, Inc. All rights reserved.
+//
+
+import Foundation
+
+/// The subscription is no longer valid and should be removed and re-registered.
+///
+/// - Warning: Do not continue to send notifications to invalid subscriptions or you'll risk being rate limited by push services.
+public struct BadSubscriberError: LocalizedError, Hashable {
+ public init() {}
+
+ public var errorDescription: String? {
+ "The subscription is no longer valid."
+ }
+}
diff --git a/Sources/WebPush/Errors/Base64URLDecodingError.swift b/Sources/WebPush/Errors/Base64URLDecodingError.swift
new file mode 100644
index 0000000..1d09a54
--- /dev/null
+++ b/Sources/WebPush/Errors/Base64URLDecodingError.swift
@@ -0,0 +1,18 @@
+//
+// Base64URLDecodingError.swift
+// swift-webpush
+//
+// Created by Dimitri Bouniol on 2024-12-13.
+// Copyright © 2024 Mochi Development, Inc. All rights reserved.
+//
+
+import Foundation
+
+/// An error encountered while decoding Base64 data.
+public struct Base64URLDecodingError: LocalizedError, Hashable {
+ public init() {}
+
+ public var errorDescription: String? {
+ "The Base64 data could not be decoded."
+ }
+}
diff --git a/Sources/WebPush/Errors/HTTPError.swift b/Sources/WebPush/Errors/HTTPError.swift
new file mode 100644
index 0000000..e520378
--- /dev/null
+++ b/Sources/WebPush/Errors/HTTPError.swift
@@ -0,0 +1,27 @@
+//
+// HTTPError.swift
+// swift-webpush
+//
+// Created by Dimitri Bouniol on 2024-12-13.
+// Copyright © 2024 Mochi Development, Inc. All rights reserved.
+//
+
+import AsyncHTTPClient
+import Foundation
+
+/// An unknown HTTP error was encountered.
+///
+/// - SeeAlso: [RFC8030 Generic Event Delivery Using HTTP Push](https://datatracker.ietf.org/doc/html/rfc8030)
+/// - SeeAlso: [RFC8292 Voluntary Application Server Identification (VAPID) for Web Push](https://datatracker.ietf.org/doc/html/rfc8292)
+/// - SeeAlso: [Sending web push notifications in web apps and browsers — Review responses for push notification errors](https://developer.apple.com/documentation/usernotifications/sending-web-push-notifications-in-web-apps-and-browsers#Review-responses-for-push-notification-errors)
+public struct HTTPError: LocalizedError {
+ let response: HTTPClientResponse
+
+ init(response: HTTPClientResponse) {
+ self.response = response
+ }
+
+ public var errorDescription: String? {
+ "A \(response.status) HTTP error was encountered: \(response)."
+ }
+}
diff --git a/Sources/WebPush/Errors/UserAgentKeyMaterialError.swift b/Sources/WebPush/Errors/UserAgentKeyMaterialError.swift
new file mode 100644
index 0000000..80e00ec
--- /dev/null
+++ b/Sources/WebPush/Errors/UserAgentKeyMaterialError.swift
@@ -0,0 +1,39 @@
+//
+// UserAgentKeyMaterialError.swift
+// swift-webpush
+//
+// Created by Dimitri Bouniol on 2024-12-13.
+// Copyright © 2024 Mochi Development, Inc. All rights reserved.
+//
+
+import Foundation
+
+/// An error encountered during ``VAPID/Configuration`` initialization or decoding.
+public struct UserAgentKeyMaterialError: LocalizedError {
+ enum Kind {
+ case invalidPublicKey
+ case invalidAuthenticationSecret
+ }
+
+ var kind: Kind
+ var underlyingError: any Error
+
+ /// The public key was invalid.
+ public static func invalidPublicKey(underlyingError: Error) -> Self {
+ Self(kind: .invalidPublicKey, underlyingError: underlyingError)
+ }
+
+ /// The authentication secret was invalid.
+ public static func invalidAuthenticationSecret(underlyingError: Error) -> Self {
+ Self(kind: .invalidAuthenticationSecret, underlyingError: underlyingError)
+ }
+
+ public var errorDescription: String? {
+ switch kind {
+ case .invalidPublicKey:
+ "Subscriber Public Key (`\(UserAgentKeyMaterial.CodingKeys.publicKey)`) was invalid: \(underlyingError.localizedDescription)."
+ case .invalidAuthenticationSecret:
+ "Subscriber Authentication Secret (`\(UserAgentKeyMaterial.CodingKeys.authenticationSecret)`) was invalid: \(underlyingError.localizedDescription)."
+ }
+ }
+}
diff --git a/Sources/WebPush/Errors/VAPIDConfigurationError.swift b/Sources/WebPush/Errors/VAPIDConfigurationError.swift
new file mode 100644
index 0000000..f8cc251
--- /dev/null
+++ b/Sources/WebPush/Errors/VAPIDConfigurationError.swift
@@ -0,0 +1,36 @@
+//
+// VAPIDConfigurationError.swift
+// swift-webpush
+//
+// Created by Dimitri Bouniol on 2024-12-13.
+// Copyright © 2024 Mochi Development, Inc. All rights reserved.
+//
+
+import Foundation
+
+extension VAPID {
+ /// An error encountered during ``VAPID/Configuration`` initialization or decoding.
+ public struct ConfigurationError: LocalizedError, Hashable {
+ enum Kind {
+ case keysNotProvided
+ case matchingKeyNotFound
+ }
+
+ var kind: Kind
+
+ /// VAPID keys not found during initialization.
+ public static let keysNotProvided = Self(kind: .keysNotProvided)
+
+ /// A VAPID key for the subscriber was not found.
+ public static let matchingKeyNotFound = Self(kind: .matchingKeyNotFound)
+
+ public var errorDescription: String? {
+ switch kind {
+ case .keysNotProvided:
+ "VAPID keys not found during initialization."
+ case .matchingKeyNotFound:
+ "A VAPID key for the subscriber was not found."
+ }
+ }
+ }
+}
diff --git a/Sources/WebPush/Subscriber.swift b/Sources/WebPush/Subscriber.swift
index 9df940e..5915bd2 100644
--- a/Sources/WebPush/Subscriber.swift
+++ b/Sources/WebPush/Subscriber.swift
@@ -67,15 +67,15 @@ public struct UserAgentKeyMaterial: Sendable {
public init(
publicKey: String,
authenticationSecret: String
- ) throws {
+ ) throws(UserAgentKeyMaterialError) {
guard let publicKeyData = Data(base64URLEncoded: publicKey)
- else { throw CancellationError() } // invalid public key error (underlying error = URLDecoding error)
+ else { throw .invalidPublicKey(underlyingError: Base64URLDecodingError()) }
do {
self.publicKey = try P256.KeyAgreement.PublicKey(x963Representation: publicKeyData)
- } catch { throw CancellationError() } // invalid public key error (underlying error = error)
+ } catch { throw .invalidPublicKey(underlyingError: error) }
guard let authenticationSecretData = Data(base64URLEncoded: authenticationSecret)
- else { throw CancellationError() } // invalid authentication secret error (underlying error = URLDecoding error)
+ else { throw .invalidAuthenticationSecret(underlyingError: Base64URLDecodingError()) }
self.authenticationSecret = authenticationSecretData
}
@@ -190,5 +190,6 @@ public struct Subscriber: SubscriberProtocol, Codable, Hashable, Sendable {
}
extension Subscriber: Identifiable {
+ /// A safe identifier to use for the subscriber without exposing key material.
public var id: String { endpoint.absoluteString }
}
diff --git a/Sources/WebPush/VAPID/VAPIDConfiguration.swift b/Sources/WebPush/VAPID/VAPIDConfiguration.swift
index 3e66cde..5d871ce 100644
--- a/Sources/WebPush/VAPID/VAPIDConfiguration.swift
+++ b/Sources/WebPush/VAPID/VAPIDConfiguration.swift
@@ -46,14 +46,14 @@ extension VoluntaryApplicationServerIdentification {
contactInformation: ContactInformation,
expirationDuration: Duration = .hours(22),
validityDuration: Duration = .hours(20)
- ) throws {
+ ) throws(ConfigurationError) {
self.primaryKey = primaryKey
var keys = keys
if let primaryKey {
keys.insert(primaryKey)
}
guard !keys.isEmpty
- else { throw CancellationError() } // TODO: No keys error
+ else { throw .keysNotProvided }
self.keys = keys
var deprecatedKeys = deprecatedKeys ?? []
@@ -88,14 +88,14 @@ extension VoluntaryApplicationServerIdentification {
primaryKey: Key?,
keys: Set,
deprecatedKeys: Set? = nil
- ) throws {
+ ) throws(ConfigurationError) {
self.primaryKey = primaryKey
var keys = keys
if let primaryKey {
keys.insert(primaryKey)
}
guard !keys.isEmpty
- else { throw CancellationError() } // TODO: No keys error
+ else { throw .keysNotProvided }
self.keys = keys
var deprecatedKeys = deprecatedKeys ?? []
diff --git a/Sources/WebPush/WebPushManager.swift b/Sources/WebPush/WebPushManager.swift
index 8e8969b..51accce 100644
--- a/Sources/WebPush/WebPushManager.swift
+++ b/Sources/WebPush/WebPushManager.swift
@@ -296,7 +296,12 @@ public actor WebPushManager: Sendable {
urgency: Urgency
) async throws {
guard let signingKey = vapidKeyLookup[subscriber.vapidKeyID]
- else { throw CancellationError() } // throw key not found error
+ else {
+ logger.warning("A key was not found for this subscriber.", metadata: [
+ "vapidKeyID": "\(subscriber.vapidKeyID)"
+ ])
+ throw VAPID.ConfigurationError.matchingKeyNotFound
+ }
/// Prepare authorization, private keys, and payload ahead of time to bail early if they can't be created.
let authorization = try loadCurrentVAPIDAuthorizationHeader(endpoint: subscriber.endpoint, signingKey: signingKey)
@@ -305,7 +310,7 @@ public actor WebPushManager: Sendable {
/// Perform key exchange between the user agent's public key and our private key, deriving a shared secret.
let userAgent = subscriber.userAgentKeyMaterial
guard let sharedSecret = try? applicationServerECDHPrivateKey.sharedSecretFromKeyAgreement(with: userAgent.publicKey)
- else { throw CancellationError() } // throw bad subscription
+ else { throw BadSubscriberError() }
/// Generate a 16-byte salt.
var salt: [UInt8] = Array(repeating: 0, count: 16)
@@ -376,8 +381,10 @@ public actor WebPushManager: Sendable {
/// Check the response and determine if the subscription should be removed from our records, or if the notification should just be skipped.
switch response.status {
case .created: break
- case .notFound, .gone: throw CancellationError() // throw bad subscription
- default: throw CancellationError() //Abort(response.status, headers: response.headers, reason: response.description)
+ case .notFound, .gone: throw BadSubscriberError()
+ // TODO: 413 payload too large - warn and throw error
+ // TODO: 429 too many requests, 500 internal server error, 503 server shutting down - check config and perform a retry after a delay?
+ default: throw HTTPError(response: response)
}
logger.trace("Sent \(message) notification to \(subscriber): \(response)")
}