diff --git a/Package.swift b/Package.swift index 6cfc564..e6421c2 100644 --- a/Package.swift +++ b/Package.swift @@ -13,6 +13,7 @@ let package = Package( ], products: [ .library(name: "WebPush", targets: ["WebPush"]), + .library(name: "WebPushTesting", targets: ["WebPush", "WebPushTesting"]), ], dependencies: [ .package(url: "https://github.com/apple/swift-crypto.git", "3.10.0"..<"5.0.0"), @@ -33,10 +34,21 @@ let package = Package( .product(name: "NIOHTTP1", package: "swift-nio"), ] ), + .target( + name: "WebPushTesting", + dependencies: [ + .product(name: "Crypto", package: "swift-crypto"), + .product(name: "Logging", package: "swift-log"), + .target(name: "WebPush"), + ] + ), .testTarget(name: "WebPushTests", dependencies: [ + .product(name: "AsyncHTTPClient", package: "async-http-client"), .product(name: "Logging", package: "swift-log"), + .product(name: "NIOCore", package: "swift-nio"), .product(name: "ServiceLifecycle", package: "swift-service-lifecycle"), .target(name: "WebPush"), + .target(name: "WebPushTesting"), ]), ] ) diff --git a/README.md b/README.md index 78c9c61..185bc5f 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ # WebPush
-
-
+
+
-
-
+
+
@@ -40,6 +40,9 @@ targets: [
"WebPush",
]
)
+ .testTarget(name: "MyPackageTests", dependencies: [
+ "WebPushTesting",
+ ]
]
```
diff --git a/Sources/WebPush/Helpers/HTTPClientProtocol.swift b/Sources/WebPush/Helpers/HTTPClientProtocol.swift
new file mode 100644
index 0000000..f95443d
--- /dev/null
+++ b/Sources/WebPush/Helpers/HTTPClientProtocol.swift
@@ -0,0 +1,23 @@
+//
+// HTTPClientProtocol.swift
+// swift-webpush
+//
+// Created by Dimitri Bouniol on 2024-12-11.
+// Copyright © 2024 Mochi Development, Inc. All rights reserved.
+//
+
+import AsyncHTTPClient
+import Logging
+import NIOCore
+
+package protocol HTTPClientProtocol: Sendable {
+ func execute(
+ _ request: HTTPClientRequest,
+ deadline: NIODeadline,
+ logger: Logger?
+ ) async throws -> HTTPClientResponse
+
+ func syncShutdown() throws
+}
+
+extension HTTPClient: HTTPClientProtocol {}
diff --git a/Sources/WebPush/Helpers/PrintLogHandler.swift b/Sources/WebPush/Helpers/PrintLogHandler.swift
index 9354763..7f1ebfe 100644
--- a/Sources/WebPush/Helpers/PrintLogHandler.swift
+++ b/Sources/WebPush/Helpers/PrintLogHandler.swift
@@ -9,13 +9,13 @@
import Foundation
import Logging
-struct PrintLogHandler: LogHandler {
+package struct PrintLogHandler: LogHandler {
private let label: String
- var logLevel: Logger.Level = .info
- var metadataProvider: Logger.MetadataProvider?
+ package var logLevel: Logger.Level = .info
+ package var metadataProvider: Logger.MetadataProvider?
- init(
+ package init(
label: String,
logLevel: Logger.Level = .info,
metadataProvider: Logger.MetadataProvider? = nil
@@ -26,13 +26,13 @@ struct PrintLogHandler: LogHandler {
}
private var prettyMetadata: String?
- var metadata = Logger.Metadata() {
+ package var metadata = Logger.Metadata() {
didSet {
self.prettyMetadata = self.prettify(self.metadata)
}
}
- subscript(metadataKey metadataKey: String) -> Logger.Metadata.Value? {
+ package subscript(metadataKey metadataKey: String) -> Logger.Metadata.Value? {
get {
self.metadata[metadataKey]
}
@@ -41,7 +41,7 @@ struct PrintLogHandler: LogHandler {
}
}
- func log(
+ package func log(
level: Logger.Level,
message: Logger.Message,
metadata explicitMetadata: Logger.Metadata?,
@@ -66,7 +66,7 @@ struct PrintLogHandler: LogHandler {
print("\(self.timestamp()) [\(level)] \(self.label):\(prettyMetadata.map { " \($0)" } ?? "") [\(source)] \(message)")
}
- internal static func prepareMetadata(
+ private static func prepareMetadata(
base: Logger.Metadata,
provider: Logger.MetadataProvider?,
explicit: Logger.Metadata?
diff --git a/Sources/WebPush/Subscriber.swift b/Sources/WebPush/Subscriber.swift
index 7a225c9..9df940e 100644
--- a/Sources/WebPush/Subscriber.swift
+++ b/Sources/WebPush/Subscriber.swift
@@ -178,6 +178,15 @@ public struct Subscriber: SubscriberProtocol, Codable, Hashable, Sendable {
self.userAgentKeyMaterial = userAgentKeyMaterial
self.vapidKeyID = vapidKeyID
}
+
+ /// Cast an object that conforms to ``SubscriberProtocol`` to a ``Subscriber``.
+ public init(_ subscriber: some SubscriberProtocol) {
+ self.init(
+ endpoint: subscriber.endpoint,
+ userAgentKeyMaterial: subscriber.userAgentKeyMaterial,
+ vapidKeyID: subscriber.vapidKeyID
+ )
+ }
}
extension Subscriber: Identifiable {
diff --git a/Sources/WebPush/WebPushManager.swift b/Sources/WebPush/WebPushManager.swift
index c467e73..8ee4142 100644
--- a/Sources/WebPush/WebPushManager.swift
+++ b/Sources/WebPush/WebPushManager.swift
@@ -12,9 +12,10 @@ import Foundation
import NIOHTTP1
import Logging
import NIOCore
+import NIOPosix
import ServiceLifecycle
-actor WebPushManager: Sendable {
+public actor WebPushManager: Sendable {
public let vapidConfiguration: VAPID.Configuration
/// The maximum encrypted payload size guaranteed by the spec.
@@ -24,7 +25,7 @@ actor WebPushManager: Sendable {
public static let maximumMessageSize = maximumEncryptedPayloadSize - 103
nonisolated let logger: Logger
- let httpClient: HTTPClient
+ var executor: Executor
let vapidKeyLookup: [VAPID.Key.ID : VAPID.Key]
var vapidAuthorizationCache: [String : (authorization: String, validUntil: Date)] = [:]
@@ -35,35 +36,53 @@ actor WebPushManager: Sendable {
logger: Logger? = nil,
eventLoopGroupProvider: NIOEventLoopGroupProvider = .shared(.singletonMultiThreadedEventLoopGroup)
) {
- assert(vapidConfiguration.validityDuration <= vapidConfiguration.expirationDuration, "The validity duration must be earlier than the expiration duration since it represents when the VAPID Authorization token will be refreshed ahead of it expiring.");
- assert(vapidConfiguration.expirationDuration <= .hours(24), "The expiration duration must be less than 24 hours or else push endpoints will reject messages sent to them.");
- precondition(!vapidConfiguration.keys.isEmpty, "VAPID.Configuration must have keys specified.")
-
- self.vapidConfiguration = vapidConfiguration
- let allKeys = vapidConfiguration.keys + Array(vapidConfiguration.deprecatedKeys ?? [])
- self.vapidKeyLookup = Dictionary(
- allKeys.map { ($0.id, $0) },
- uniquingKeysWith: { first, _ in first }
- )
-
- self.logger = Logger(label: "WebPushManager", factory: { logger?.handler ?? PrintLogHandler(label: $0, metadataProvider: $1) })
+ let logger = Logger(label: "WebPushManager", factory: { logger?.handler ?? PrintLogHandler(label: $0, metadataProvider: $1) })
var httpClientConfiguration = HTTPClient.Configuration()
httpClientConfiguration.httpVersion = .automatic
- switch eventLoopGroupProvider {
+ let executor: Executor = switch eventLoopGroupProvider {
case .shared(let eventLoopGroup):
- self.httpClient = HTTPClient(
+ .httpClient(HTTPClient(
eventLoopGroupProvider: .shared(eventLoopGroup),
configuration: httpClientConfiguration,
- backgroundActivityLogger: self.logger
- )
+ backgroundActivityLogger: logger
+ ))
case .createNew:
- self.httpClient = HTTPClient(
+ .httpClient(HTTPClient(
configuration: httpClientConfiguration,
- backgroundActivityLogger: self.logger
- )
+ backgroundActivityLogger: logger
+ ))
}
+
+ self.init(
+ vapidConfiguration: vapidConfiguration,
+ logger: logger,
+ executor: executor
+ )
+ }
+
+ /// Internal method to install a different executor for mocking.
+ ///
+ /// Note that this must be called before ``run()`` is called or the client's syncShutdown won't be called.
+ package init(
+ vapidConfiguration: VAPID.Configuration,
+ // TODO: Add networkConfiguration for proxy, number of simultaneous pushes, etc…
+ logger: Logger,
+ executor: Executor
+ ) {
+ assert(vapidConfiguration.validityDuration <= vapidConfiguration.expirationDuration, "The validity duration must be earlier than the expiration duration since it represents when the VAPID Authorization token will be refreshed ahead of it expiring.");
+ assert(vapidConfiguration.expirationDuration <= .hours(24), "The expiration duration must be less than 24 hours or else push endpoints will reject messages sent to them.");
+ precondition(!vapidConfiguration.keys.isEmpty, "VAPID.Configuration must have keys specified.")
+
+ self.vapidConfiguration = vapidConfiguration
+ let allKeys = vapidConfiguration.keys + Array(vapidConfiguration.deprecatedKeys ?? [])
+ self.vapidKeyLookup = Dictionary(
+ allKeys.map { ($0.id, $0) },
+ uniquingKeysWith: { first, _ in first }
+ )
+ self.logger = logger
+ self.executor = executor
}
/// Load an up-to-date Authorization header for the specified endpoint and signing key combo.
@@ -156,11 +175,125 @@ actor WebPushManager: Sendable {
vapidConfiguration.primaryKey?.id ?? vapidConfiguration.keys.randomElement()!.id
}
+ /// Send a push message as raw data.
+ ///
+ /// The service worker you registered is expected to know how to decode the data you send.
+ ///
+ /// - Parameters:
+ /// - message: The message to send as raw data.
+ /// - subscriber: The subscriber to send the push message to.
+ /// - expiration: The expiration of the push message, after wich delivery will no longer be attempted.
+ /// - urgency: The urgency of the delivery of the push message.
public func send(
data message: some DataProtocol,
to subscriber: some SubscriberProtocol,
expiration: VAPID.Configuration.Duration = .days(30),
urgency: Urgency = .high
+ ) async throws {
+ switch executor {
+ case .httpClient(let httpClient):
+ try await execute(
+ httpClient: httpClient,
+ data: message,
+ subscriber: subscriber,
+ expiration: expiration,
+ urgency: urgency
+ )
+ case .handler(let handler):
+ try await handler(.data(Data(message)), Subscriber(subscriber), expiration, urgency)
+ }
+ }
+
+ /// Send a push message as a string.
+ ///
+ /// The service worker you registered is expected to know how to decode the string you send.
+ ///
+ /// - Parameters:
+ /// - message: The message to send as a string.
+ /// - subscriber: The subscriber to send the push message to.
+ /// - expiration: The expiration of the push message, after wich delivery will no longer be attempted.
+ /// - urgency: The urgency of the delivery of the push message.
+ public func send(
+ string message: some StringProtocol,
+ to subscriber: some SubscriberProtocol,
+ expiration: VAPID.Configuration.Duration = .days(30),
+ urgency: Urgency = .high
+ ) async throws {
+ try await routeMessage(
+ message: .string(String(message)),
+ to: subscriber,
+ expiration: expiration,
+ urgency: urgency
+ )
+ }
+
+ /// Send a push message as encoded JSON.
+ ///
+ /// The service worker you registered is expected to know how to decode the JSON you send. Note that dates are encoded using ``/Foundation/JSONEncoder/DateEncodingStrategy/millisecondsSince1970``, and data is encoded using ``/Foundation/JSONEncoder/DataEncodingStrategy/base64``.
+ ///
+ /// - Parameters:
+ /// - message: The message to send as JSON.
+ /// - subscriber: The subscriber to send the push message to.
+ /// - expiration: The expiration of the push message, after wich delivery will no longer be attempted.
+ /// - urgency: The urgency of the delivery of the push message.
+ public func send(
+ json message: some Encodable&Sendable,
+ to subscriber: some SubscriberProtocol,
+ expiration: VAPID.Configuration.Duration = .days(30),
+ urgency: Urgency = .high
+ ) async throws {
+ try await routeMessage(
+ message: .json(message),
+ to: subscriber,
+ expiration: expiration,
+ urgency: urgency
+ )
+ }
+
+ /// Route a message to the current executor.
+ /// - Parameters:
+ /// - message: The message to send.
+ /// - subscriber: The subscriber to sign the message against.
+ /// - expiration: The expiration of the message.
+ /// - urgency: The urgency of the message.
+ func routeMessage(
+ message: _Message,
+ to subscriber: some SubscriberProtocol,
+ expiration: VAPID.Configuration.Duration,
+ urgency: Urgency
+ ) async throws {
+ switch executor {
+ case .httpClient(let httpClient):
+ try await execute(
+ httpClient: httpClient,
+ data: message.data,
+ subscriber: subscriber,
+ expiration: expiration,
+ urgency: urgency
+ )
+ case .handler(let handler):
+ try await handler(
+ message,
+ Subscriber(subscriber),
+ expiration,
+ urgency
+ )
+ }
+ }
+
+ /// Send a message via HTTP Client, mocked or otherwise, encrypting it on the way.
+ /// - Parameters:
+ /// - httpClient: The protocol implementing HTTP-like functionality.
+ /// - message: The message to send as raw data.
+ /// - subscriber: The subscriber to sign the message against.
+ /// - expiration: The expiration of the message.
+ /// - urgency: The urgency of the message.
+ func execute(
+ httpClient: some HTTPClientProtocol,
+ data message: some DataProtocol,
+ subscriber: some SubscriberProtocol,
+ expiration: VAPID.Configuration.Duration,
+ urgency: Urgency
) async throws {
guard let signingKey = vapidKeyLookup[subscriber.vapidKeyID]
else { throw CancellationError() } // throw key not found error
@@ -255,10 +388,12 @@ extension WebPushManager: Service {
logger.info("Starting up WebPushManager")
try await withTaskCancellationOrGracefulShutdownHandler {
try await gracefulShutdown()
- } onCancelOrGracefulShutdown: { [self] in
+ } onCancelOrGracefulShutdown: { [logger, executor] in
logger.info("Shutting down WebPushManager")
do {
- try httpClient.syncShutdown()
+ if case let .httpClient(httpClient) = executor {
+ try httpClient.syncShutdown()
+ }
} catch {
logger.error("Graceful Shutdown Failed", metadata: [
"error": "\(error)"
@@ -268,34 +403,38 @@ extension WebPushManager: Service {
}
}
-public struct Urgency: Hashable, Comparable, Sendable, CustomStringConvertible {
- let rawValue: String
-
- public static let veryLow = Self(rawValue: "very-low")
- public static let low = Self(rawValue: "low")
- public static let normal = Self(rawValue: "normal")
- public static let high = Self(rawValue: "high")
-
- @usableFromInline
- var comparableValue: Int {
- switch self {
- case .high: 4
- case .normal: 3
- case .low: 2
- case .veryLow: 1
- default: 0
+// MARK: - Public Types
+
+extension WebPushManager {
+ public struct Urgency: Hashable, Comparable, Sendable, CustomStringConvertible {
+ let rawValue: String
+
+ public static let veryLow = Self(rawValue: "very-low")
+ public static let low = Self(rawValue: "low")
+ public static let normal = Self(rawValue: "normal")
+ public static let high = Self(rawValue: "high")
+
+ @usableFromInline
+ var comparableValue: Int {
+ switch self {
+ case .high: 4
+ case .normal: 3
+ case .low: 2
+ case .veryLow: 1
+ default: 0
+ }
}
+
+ @inlinable
+ public static func < (lhs: Self, rhs: Self) -> Bool {
+ lhs.comparableValue < rhs.comparableValue
+ }
+
+ public var description: String { rawValue }
}
-
- @inlinable
- public static func < (lhs: Self, rhs: Self) -> Bool {
- lhs.comparableValue < rhs.comparableValue
- }
-
- public var description: String { rawValue }
}
-extension Urgency: Codable {
+extension WebPushManager.Urgency: Codable {
public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
self.rawValue = try container.decode(String.self)
@@ -306,3 +445,39 @@ extension Urgency: Codable {
try container.encode(rawValue)
}
}
+
+// MARK: - Package Types
+
+extension WebPushManager {
+ public enum _Message: Sendable {
+ case data(Data)
+ case string(String)
+ case json(any Encodable&Sendable)
+
+ var data: Data {
+ get throws {
+ switch self {
+ case .data(let data):
+ return data
+ case .string(let string):
+ var string = string
+ return string.withUTF8 { Data($0) }
+ case .json(let json):
+ let encoder = JSONEncoder()
+ encoder.dateEncodingStrategy = .millisecondsSince1970
+ return try encoder.encode(json)
+ }
+ }
+ }
+ }
+
+ package enum Executor: Sendable {
+ case httpClient(any HTTPClientProtocol)
+ case handler(@Sendable (
+ _ message: _Message,
+ _ subscriber: Subscriber,
+ _ expiration: VAPID.Configuration.Duration,
+ _ urgency: Urgency
+ ) async throws -> Void)
+ }
+}
diff --git a/Sources/WebPushTesting/VAPIDConfiguration+Testing.swift b/Sources/WebPushTesting/VAPIDConfiguration+Testing.swift
new file mode 100644
index 0000000..dd4a23a
--- /dev/null
+++ b/Sources/WebPushTesting/VAPIDConfiguration+Testing.swift
@@ -0,0 +1,23 @@
+//
+// VAPIDConfiguration+Testing.swift
+// swift-webpush
+//
+// Created by Dimitri Bouniol on 2024-12-12.
+// Copyright © 2024 Mochi Development, Inc. All rights reserved.
+//
+
+@preconcurrency import Crypto
+import Foundation
+import WebPush
+
+extension VAPID.Configuration {
+ /// A mocked configuration useful when testing with the library, since the mocked manager doesn't make use of it anyways.
+ public static var mocked: Self {
+ /// Generated using `P256.Signing.PrivateKey(compactRepresentable: false).x963Representation.base64EncodedString()`.
+ let privateKey = try! P256.Signing.PrivateKey(x963Representation: Data(base64Encoded: "BGEhWik09/s/JNkl0OAcTIdRTb7AoLRZQQG4C96OhlcFVQYH5kMWUML3MZBG3gPXxN1Njn6uXulDysPGMDBR47SurTnyXnbuaJ7VDm3UsVYUs5kFoZM8VB5QtoKpgE7WyQ==")!)
+ return VAPID.Configuration(
+ key: .init(privateKey: privateKey),
+ contactInformation: .email("test@example.com")
+ )
+ }
+}
diff --git a/Sources/WebPushTesting/WebPushManager+Testing.swift b/Sources/WebPushTesting/WebPushManager+Testing.swift
new file mode 100644
index 0000000..c7273ca
--- /dev/null
+++ b/Sources/WebPushTesting/WebPushManager+Testing.swift
@@ -0,0 +1,43 @@
+//
+// WebPushManager+Testing.swift
+// swift-webpush
+//
+// Created by Dimitri Bouniol on 2024-12-12.
+// Copyright © 2024 Mochi Development, Inc. All rights reserved.
+//
+
+import Logging
+import WebPush
+
+extension WebPushManager {
+ public typealias Message = _Message
+
+ /// Create a mocked web push manager.
+ ///
+ /// The mocked manager will forward all messages as is to its message handler so that you may either verify that a push was sent, or inspect the contents of the message that was sent.
+ ///
+ /// - Parameters:
+ /// - vapidConfiguration: A VAPID configuration, though the mocked manager doesn't make use of it.
+ /// - logger: An optional logger.
+ /// - messageHandler: A handler to receive messages or throw errors.
+ /// - Returns: A new manager suitable for mocking.
+ public static func makeMockedManager(
+ vapidConfiguration: VAPID.Configuration = .mocked,
+ // TODO: Add networkConfiguration for proxy, number of simultaneous pushes, etc…
+ logger: Logger? = nil,
+ messageHandler: @escaping @Sendable (
+ _ message: Message,
+ _ subscriber: Subscriber,
+ _ expiration: VAPID.Configuration.Duration,
+ _ urgency: Urgency
+ ) async throws -> Void
+ ) -> WebPushManager {
+ let logger = Logger(label: "MockWebPushManager", factory: { logger?.handler ?? PrintLogHandler(label: $0, metadataProvider: $1) })
+
+ return WebPushManager(
+ vapidConfiguration: vapidConfiguration,
+ logger: logger,
+ executor: .handler(messageHandler)
+ )
+ }
+}
diff --git a/Tests/WebPushTests/MockHTTPClient.swift b/Tests/WebPushTests/MockHTTPClient.swift
new file mode 100644
index 0000000..3dfc004
--- /dev/null
+++ b/Tests/WebPushTests/MockHTTPClient.swift
@@ -0,0 +1,30 @@
+//
+// MockHTTPClient.swift
+// swift-webpush
+//
+// Created by Dimitri Bouniol on 2024-12-11.
+// Copyright © 2024 Mochi Development, Inc. All rights reserved.
+//
+
+import AsyncHTTPClient
+import Logging
+import NIOCore
+@testable import WebPush
+
+actor MockHTTPClient: HTTPClientProtocol {
+ var processRequest: (HTTPClientRequest) async throws -> HTTPClientResponse
+
+ init(_ processRequest: @escaping (HTTPClientRequest) async throws -> HTTPClientResponse) {
+ self.processRequest = processRequest
+ }
+
+ func execute(
+ _ request: HTTPClientRequest,
+ deadline: NIODeadline,
+ logger: Logger?
+ ) async throws -> HTTPClientResponse {
+ try await processRequest(request)
+ }
+
+ nonisolated func syncShutdown() throws {}
+}