diff --git a/Sources/WebPush/VAPID/VAPIDConfiguration.swift b/Sources/WebPush/VAPID/VAPIDConfiguration.swift index b07e887..0ab55cb 100644 --- a/Sources/WebPush/VAPID/VAPIDConfiguration.swift +++ b/Sources/WebPush/VAPID/VAPIDConfiguration.swift @@ -259,13 +259,18 @@ extension VAPID.Configuration { case unknown } + /// A duration in seconds used to express when VAPID tokens will expire. public struct Duration: Hashable, Comparable, Codable, ExpressibleByIntegerLiteral, AdditiveArithmetic, Sendable { + /// The number of seconds represented by this duration. public let seconds: Int + /// Initialize a duration with a number of seconds. + @inlinable public init(seconds: Int) { self.seconds = seconds } + @inlinable public static func < (lhs: Self, rhs: Self) -> Bool { lhs.seconds < rhs.seconds } @@ -280,37 +285,49 @@ extension VAPID.Configuration { try container.encode(self.seconds) } + @inlinable public init(integerLiteral value: Int) { self.seconds = value } + @inlinable public static func - (lhs: Self, rhs: Self) -> Self { Self(seconds: lhs.seconds - rhs.seconds) } + @inlinable public static func + (lhs: Self, rhs: Self) -> Self { Self(seconds: lhs.seconds + rhs.seconds) } + /// Make a duration with a number of seconds. + @inlinable public static func seconds(_ seconds: Int) -> Self { Self(seconds: seconds) } + /// Make a duration with a number of minutes. + @inlinable public static func minutes(_ minutes: Int) -> Self { - Self(seconds: minutes*60) + .seconds(minutes*60) } + /// Make a duration with a number of hours. + @inlinable public static func hours(_ hours: Int) -> Self { - Self(seconds: hours*60*60) + .minutes(hours*60) } + /// Make a duration with a number of days. + @inlinable public static func days(_ days: Int) -> Self { - Self(seconds: days*24*60*60) + .hours(days*24) } } } extension Date { + /// Helper to add a duration to a date. func adding(_ duration: VAPID.Configuration.Duration) -> Self { addingTimeInterval(TimeInterval(duration.seconds)) } diff --git a/Sources/WebPush/WebPushManager.swift b/Sources/WebPush/WebPushManager.swift index acf892d..7f8082b 100644 --- a/Sources/WebPush/WebPushManager.swift +++ b/Sources/WebPush/WebPushManager.swift @@ -199,7 +199,7 @@ public actor WebPushManager: Sendable { public func send( data message: some DataProtocol, to subscriber: some SubscriberProtocol, - expiration: VAPID.Configuration.Duration = .days(30), + expiration: Expiration = .recommendedMaximum, urgency: Urgency = .high ) async throws { switch executor { @@ -228,7 +228,7 @@ public actor WebPushManager: Sendable { public func send( string message: some StringProtocol, to subscriber: some SubscriberProtocol, - expiration: VAPID.Configuration.Duration = .days(30), + expiration: Expiration = .recommendedMaximum, urgency: Urgency = .high ) async throws { try await routeMessage( @@ -251,7 +251,7 @@ public actor WebPushManager: Sendable { public func send( json message: some Encodable&Sendable, to subscriber: some SubscriberProtocol, - expiration: VAPID.Configuration.Duration = .days(30), + expiration: Expiration = .recommendedMaximum, urgency: Urgency = .high ) async throws { try await routeMessage( @@ -271,7 +271,7 @@ public actor WebPushManager: Sendable { func routeMessage( message: _Message, to subscriber: some SubscriberProtocol, - expiration: VAPID.Configuration.Duration, + expiration: Expiration, urgency: Urgency ) async throws { switch executor { @@ -304,7 +304,7 @@ public actor WebPushManager: Sendable { httpClient: some HTTPClientProtocol, data message: some DataProtocol, subscriber: some SubscriberProtocol, - expiration: VAPID.Configuration.Duration, + expiration: Expiration, urgency: Urgency ) async throws { guard let signingKey = vapidKeyLookup[subscriber.vapidKeyID] @@ -377,13 +377,19 @@ public actor WebPushManager: Sendable { /// Attach the header with our public key and salt, along with the authentication tag. let requestContent = contentCodingHeader + encryptedRecord.ciphertext + encryptedRecord.tag + if expiration < Expiration.dropIfUndeliverable { + logger.error("The message expiration must be greater than or equal to \(Expiration.dropIfUndeliverable) seconds.", metadata: ["expiration": "\(expiration)"]) + } else if expiration >= Expiration.recommendedMaximum { + logger.warning("The message expiration should be less than \(Expiration.recommendedMaximum) seconds.", metadata: ["expiration": "\(expiration)"]) + } + /// Add the VAPID authorization and corrent content encoding and type. var request = HTTPClientRequest(url: subscriber.endpoint.absoluteURL.absoluteString) request.method = .POST request.headers.add(name: "Authorization", value: authorization) request.headers.add(name: "Content-Encoding", value: "aes128gcm") request.headers.add(name: "Content-Type", value: "application/octet-stream") - request.headers.add(name: "TTL", value: "\(expiration.seconds)") + request.headers.add(name: "TTL", value: "\(max(expiration, .dropIfUndeliverable).seconds)") request.headers.add(name: "Urgency", value: "\(urgency)") request.body = .bytes(ByteBuffer(bytes: requestContent)) @@ -424,6 +430,90 @@ extension WebPushManager: Service { // MARK: - Public Types +extension WebPushManager { + /// A duration in seconds used to express when push messages will expire. + /// + /// - SeeAlso: [RFC 8030 Generic Event Delivery Using HTTP §5.2. Push Message Time-To-Live](https://datatracker.ietf.org/doc/html/rfc8030#section-5.2) + public struct Expiration: Hashable, Comparable, Codable, ExpressibleByIntegerLiteral, AdditiveArithmetic, Sendable { + /// The number of seconds represented by this expiration. + public let seconds: Int + + /// The recommended maximum expiration duration push services are expected to support. + /// + /// - Note: A time of 30 days was chosen to match the maximum Apple Push Notification Services (APNS) accepts, but there is otherwise no recommended value here. Note that other services are instead limited to 4 weeks, or 28 days. + public static let recommendedMaximum: Self = .days(30) + + /// The message will be delivered immediately, otherwise it'll be dropped. + /// + /// A Push message with a zero TTL is immediately delivered if the user agent is available to receive the message. After delivery, the push service is permitted to immediately remove a push message with a zero TTL. This might occur before the user agent acknowledges receipt of the message by performing an HTTP DELETE on the push message resource. Consequently, an application server cannot rely on receiving acknowledgement receipts for zero TTL push messages. + /// + /// If the user agent is unavailable, a push message with a zero TTL expires and is never delivered. + /// + /// - SeeAlso: [RFC 8030 Generic Event Delivery Using HTTP §5.2. Push Message Time-To-Live](https://datatracker.ietf.org/doc/html/rfc8030#section-5.2) + public static let dropIfUndeliverable: Self = .zero + + /// Initialize an expiration with a number of seconds. + @inlinable + public init(seconds: Int) { + self.seconds = seconds + } + + @inlinable + public static func < (lhs: Self, rhs: Self) -> Bool { + lhs.seconds < rhs.seconds + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + self.seconds = try container.decode(Int.self) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(self.seconds) + } + + @inlinable + public init(integerLiteral value: Int) { + self.seconds = value + } + + @inlinable + public static func - (lhs: Self, rhs: Self) -> Self { + Self(seconds: lhs.seconds - rhs.seconds) + } + + @inlinable + public static func + (lhs: Self, rhs: Self) -> Self { + Self(seconds: lhs.seconds + rhs.seconds) + } + + /// Make an expiration with a number of seconds. + @inlinable + public static func seconds(_ seconds: Int) -> Self { + Self(seconds: seconds) + } + + /// Make an expiration with a number of minutes. + @inlinable + public static func minutes(_ minutes: Int) -> Self { + .seconds(minutes*60) + } + + /// Make an expiration with a number of hours. + @inlinable + public static func hours(_ hours: Int) -> Self { + .minutes(hours*60) + } + + /// Make an expiration with a number of days. + @inlinable + public static func days(_ days: Int) -> Self { + .hours(days*24) + } + } +} + extension WebPushManager { public struct Urgency: Hashable, Comparable, Sendable, CustomStringConvertible { let rawValue: String @@ -495,7 +585,7 @@ extension WebPushManager { case handler(@Sendable ( _ message: _Message, _ subscriber: Subscriber, - _ expiration: VAPID.Configuration.Duration, + _ expiration: Expiration, _ urgency: Urgency ) async throws -> Void) } diff --git a/Sources/WebPushTesting/WebPushManager+Testing.swift b/Sources/WebPushTesting/WebPushManager+Testing.swift index c7273ca..08f195d 100644 --- a/Sources/WebPushTesting/WebPushManager+Testing.swift +++ b/Sources/WebPushTesting/WebPushManager+Testing.swift @@ -28,7 +28,7 @@ extension WebPushManager { messageHandler: @escaping @Sendable ( _ message: Message, _ subscriber: Subscriber, - _ expiration: VAPID.Configuration.Duration, + _ expiration: Expiration, _ urgency: Urgency ) async throws -> Void ) -> WebPushManager { diff --git a/Tests/WebPushTests/VAPIDConfigurationTests.swift b/Tests/WebPushTests/VAPIDConfigurationTests.swift index 6e60b62..0244c2d 100644 --- a/Tests/WebPushTests/VAPIDConfigurationTests.swift +++ b/Tests/WebPushTests/VAPIDConfigurationTests.swift @@ -261,6 +261,54 @@ struct VAPIDConfigurationTests { ) } } + + @Suite + struct Duration { + @Test func makingDurations() { + #expect(VAPID.Configuration.Duration.zero.seconds == 0) + + #expect(VAPID.Configuration.Duration(seconds: 15).seconds == 15) + #expect(VAPID.Configuration.Duration(seconds: -15).seconds == -15) + + #expect((15 as VAPID.Configuration.Duration).seconds == 15) + #expect((-15 as VAPID.Configuration.Duration).seconds == -15) + + #expect(VAPID.Configuration.Duration.seconds(15).seconds == 15) + #expect(VAPID.Configuration.Duration.seconds(-15).seconds == -15) + + #expect(VAPID.Configuration.Duration.minutes(15).seconds == 900) + #expect(VAPID.Configuration.Duration.minutes(-15).seconds == -900) + + #expect(VAPID.Configuration.Duration.hours(15).seconds == 54_000) + #expect(VAPID.Configuration.Duration.hours(-15).seconds == -54_000) + + #expect(VAPID.Configuration.Duration.days(15).seconds == 1_296_000) + #expect(VAPID.Configuration.Duration.days(-15).seconds == -1_296_000) + } + + @Test func arithmatic() { + let base: VAPID.Configuration.Duration = 15 + #expect((base + 15).seconds == 30) + #expect((base - 15).seconds == 0) + + #expect((base - .seconds(30)) == -15) + #expect((base + .minutes(2)) == 135) + #expect((base + .minutes(2) + .hours(1)) == 3_735) + #expect((base + .minutes(2) + .hours(1) + .days(2)) == 176_535) + #expect((base + .seconds(45) + .minutes(59)) == .hours(1)) + } + + @Test func comparison() { + #expect(VAPID.Configuration.Duration.seconds(75) < VAPID.Configuration.Duration.minutes(2)) + #expect(VAPID.Configuration.Duration.seconds(175) > VAPID.Configuration.Duration.minutes(2)) + } + + @Test func coding() throws { + #expect(String(decoding: try JSONEncoder().encode(VAPID.Configuration.Duration(60)), as: UTF8.self) == "60") + + #expect(try JSONDecoder().decode(VAPID.Configuration.Duration.self, from: Data("60".utf8)) == .minutes(1)) + } + } } @Suite("Contact Information Coding") diff --git a/Tests/WebPushTests/WebPushTests.swift b/Tests/WebPushTests/WebPushTests.swift index 48f1353..9a25d46 100644 --- a/Tests/WebPushTests/WebPushTests.swift +++ b/Tests/WebPushTests/WebPushTests.swift @@ -6,31 +6,88 @@ // Copyright © 2024 Mochi Development, Inc. All rights reserved. // +import Foundation import Logging import ServiceLifecycle import Testing @testable import WebPush -@Test func webPushManagerInitializesOnItsOwn() async throws { - let manager = WebPushManager(vapidConfiguration: .makeTesting()) - await withThrowingTaskGroup(of: Void.self) { group in - group.addTask { - try await manager.run() +@Suite("WebPush Manager") +struct WebPushManagerTests { + @Test func webPushManagerInitializesOnItsOwn() async throws { + let manager = WebPushManager(vapidConfiguration: .makeTesting()) + await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await manager.run() + } + group.cancelAll() } - group.cancelAll() } -} - -@Test func webPushManagerInitializesAsService() async throws { - let logger = Logger(label: "ServiceLogger", factory: { PrintLogHandler(label: $0, metadataProvider: $1) }) - let manager = WebPushManager( - vapidConfiguration: .makeTesting(), - logger: logger - ) - await withThrowingTaskGroup(of: Void.self) { group in - group.addTask { - try await ServiceGroup(services: [manager], logger: logger).run() + + @Test func webPushManagerInitializesAsService() async throws { + let logger = Logger(label: "ServiceLogger", factory: { PrintLogHandler(label: $0, metadataProvider: $1) }) + let manager = WebPushManager( + vapidConfiguration: .makeTesting(), + logger: logger + ) + await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await ServiceGroup(services: [manager], logger: logger).run() + } + group.cancelAll() + } + } + + @Suite + struct Expiration { + @Test func stableConstants() { + #expect(WebPushManager.Expiration.dropIfUndeliverable == 0) + #expect(WebPushManager.Expiration.recommendedMaximum == 2_592_000) + } + + @Test func makingExpirations() { + #expect(WebPushManager.Expiration.zero.seconds == 0) + + #expect(WebPushManager.Expiration(seconds: 15).seconds == 15) + #expect(WebPushManager.Expiration(seconds: -15).seconds == -15) + + #expect((15 as WebPushManager.Expiration).seconds == 15) + #expect((-15 as WebPushManager.Expiration).seconds == -15) + + #expect(WebPushManager.Expiration.seconds(15).seconds == 15) + #expect(WebPushManager.Expiration.seconds(-15).seconds == -15) + + #expect(WebPushManager.Expiration.minutes(15).seconds == 900) + #expect(WebPushManager.Expiration.minutes(-15).seconds == -900) + + #expect(WebPushManager.Expiration.hours(15).seconds == 54_000) + #expect(WebPushManager.Expiration.hours(-15).seconds == -54_000) + + #expect(WebPushManager.Expiration.days(15).seconds == 1_296_000) + #expect(WebPushManager.Expiration.days(-15).seconds == -1_296_000) + } + + @Test func arithmatic() { + let base: WebPushManager.Expiration = 15 + #expect((base + 15).seconds == 30) + #expect((base - 15).seconds == 0) + + #expect((base - .seconds(30)) == -15) + #expect((base + .minutes(2)) == 135) + #expect((base + .minutes(2) + .hours(1)) == 3_735) + #expect((base + .minutes(2) + .hours(1) + .days(2)) == 176_535) + #expect((base + .seconds(45) + .minutes(59)) == .hours(1)) + } + + @Test func comparison() { + #expect(WebPushManager.Expiration.seconds(75) < WebPushManager.Expiration.minutes(2)) + #expect(WebPushManager.Expiration.seconds(175) > WebPushManager.Expiration.minutes(2)) + } + + @Test func coding() throws { + #expect(String(decoding: try JSONEncoder().encode(WebPushManager.Expiration(60)), as: UTF8.self) == "60") + + #expect(try JSONDecoder().decode(WebPushManager.Expiration.self, from: Data("60".utf8)) == .minutes(1)) } - group.cancelAll() } }