Skip to content

Expirations #22

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Dec 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions Sources/WebPush/VAPID/VAPIDConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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))
}
Expand Down
104 changes: 97 additions & 7 deletions Sources/WebPush/WebPushManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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 {
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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))

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -495,7 +585,7 @@ extension WebPushManager {
case handler(@Sendable (
_ message: _Message,
_ subscriber: Subscriber,
_ expiration: VAPID.Configuration.Duration,
_ expiration: Expiration,
_ urgency: Urgency
) async throws -> Void)
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/WebPushTesting/WebPushManager+Testing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
48 changes: 48 additions & 0 deletions Tests/WebPushTests/VAPIDConfigurationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading
Loading