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

- - + + - - + + Test Status @@ -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 {} +}