Skip to content
Open
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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,15 @@ val pushService = WebPushService(
Once the service is set up, you're ready to send a push notification.

```kotlin
val subscriptionState = pushService.send(
val subscriptionState = pushService.send(Notification(
payload = "Example Notification",
endpoint = subscription.endpoint, // "https://fcm.googleapis.com/fcm/send/...",
p256dh = subscription.keys.p256dh, // "BPzdj8OB06SepRit5FpHUsaEPfs...",
auth = subscription.keys.auth, // "hv2EhUZIbsWt8CJ...",
)
))
```

#### Available arguments
#### Available `Notification` constructor arguments

- `endpoint` - The URL endpoint that identifies the push service subscription.
- `p256dh` - The P256DH key for authentication with the push service provider.
Expand Down
73 changes: 73 additions & 0 deletions src/main/kotlin/com/interaso/webpush/Notification.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package com.interaso.webpush

/**
* Represents a web push notification.
*
* @property payload The message payload to be sent in the push notification.
* @property endpoint The URL endpoint that identifies the push service subscription.
* @property p256dh The P256DH key for authentication with the push service provider.
* @property auth The authentication secret for the push service provider.
* @property ttl The time-to-live value for the push notification (optional).
* @property topic The topic of the push notification (optional).
* @property urgency The urgency level of the push notification (optional).
*
* @constructor Creates a Notification instance for a raw [payload], [p256dh] key, and [auth] secret.
*/
public data class Notification(
val payload: ByteArray,
val endpoint: String,
val p256dh: ByteArray,
val auth: ByteArray,
val ttl: Int? = null,
val topic: String? = null,
val urgency: WebPush.Urgency? = null
) {
/**
* Creates a Notification instance, encoding the [payload] using UTF-8 and decoding the [p256dh] key and [auth]
* secret to a [ByteArray] using Base64.
*
* @param payload The message payload to be sent in the push notification.
* @param endpoint The URL endpoint that identifies the push service subscription.
* @param p256dh The Base64-encoded P256DH key for authentication with the push service provider.
* @param auth The Base64-encoded authentication secret for the push service provider.
* @param ttl The time-to-live value for the push notification (optional).
* @param topic The topic of the push notification (optional).
* @param urgency The urgency level of the push notification (optional).
*/
public constructor(
payload: String,
endpoint: String,
p256dh: String,
auth: String,
ttl: Int? = null,
topic: String? = null,
urgency: WebPush.Urgency? = null
) : this(payload.toByteArray(), endpoint, decodeBase64(p256dh), decodeBase64(auth), ttl, topic, urgency)

override fun equals(other: Any?): Boolean {
if (this === other) {
return true
}
if (other !is Notification) {
return false
}
return payload.contentEquals(other.payload)
&& (endpoint == other.endpoint)
&& p256dh.contentEquals(other.p256dh)
&& auth.contentEquals(other.auth)
&& (ttl != other.ttl)
&& (topic != other.topic)
&& (urgency != other.urgency)
}

override fun hashCode(): Int {
var result = payload.contentHashCode()
result = 31 * result + endpoint.hashCode()
result = 31 * result + p256dh.contentHashCode()
result = 31 * result + auth.contentHashCode()
result = 31 * result + (ttl ?: 0)
result = 31 * result + (topic?.hashCode() ?: 0)
result = 31 * result + (urgency?.hashCode() ?: 0)
return result
}
}
12 changes: 6 additions & 6 deletions src/main/kotlin/com/interaso/webpush/WebPush.kt
Original file line number Diff line number Diff line change
Expand Up @@ -120,17 +120,17 @@ public class WebPush(
* @param statusCode the status code received from the server
* @param body the response body received from the server (optional)
* @return the subscription state based on the provided status code
* @throws WebPushStatusException if authentication failed (status code 401 or 403),
* if the service is unavailable (status code 502 or 503),
* or if an unexpected response is received
* @throws WebPushException if authentication failed (status code 401 or 403),
* if the service is unavailable (status code 502 or 503),
* or if an unexpected response is received
*/
public fun getSubscriptionState(statusCode: Int, body: String? = null): SubscriptionState {
return when (statusCode) {
200, 201, 202 -> SubscriptionState.ACTIVE
404, 410 -> SubscriptionState.EXPIRED
401, 403 -> throw WebPushStatusException(statusCode, "Authentication failed: [$statusCode] - $body")
502, 503 -> throw WebPushStatusException(statusCode, "Service unavailable: [$statusCode] - $body")
else -> throw WebPushStatusException(statusCode, "Unexpected response: [$statusCode] - $body")
401, 403 -> throw WebPushException("Authentication failed: [$statusCode] - $body")
502, 503 -> throw WebPushException("Service unavailable: [$statusCode] - $body")
else -> throw WebPushException("Unexpected response: [$statusCode] - $body")
}
}

Expand Down
15 changes: 1 addition & 14 deletions src/main/kotlin/com/interaso/webpush/WebPushException.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,7 @@ package com.interaso.webpush
* @param message The detail message of the exception.
* @param cause The cause of the exception, or null if the cause is nonexistent or unknown.
*/
public sealed class WebPushException(
public open class WebPushException(
message: String,
cause: Throwable? = null,
) : Exception(message, cause)

/**
* Represents an exception that occurs during web push sending.
*
* @param statusCode The HTTP status code returned by server
* @param message The detail message of the exception.
* @param cause The cause of the exception, or null if the cause is nonexistent or unknown.
*/
public class WebPushStatusException(
public val statusCode: Int,
message: String,
cause: Throwable? = null,
) : WebPushException(message, cause)
104 changes: 85 additions & 19 deletions src/main/kotlin/com/interaso/webpush/WebPushService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,20 @@ import java.net.http.HttpResponse.*

/**
* Represents a service for sending web push notifications.
*
* @property subject The subject identifying the push notification sender. It must start with "mailto:" or "https://".
* @property vapidKeys The VapidKeys object containing the public and private keys for VAPID authentication.
*/
public class WebPushService(
public val subject: String,
public val vapidKeys: VapidKeys,
) {
private val webPush = WebPush(subject, vapidKeys)
private val httpClient = HttpClient.newBuilder().build()
public abstract class WebPushService(protected val webPush: WebPush) {

/**
* The subject identifying the push notification sender. It must start with "mailto:" or "https://".
*/
public val subject: String
get() = webPush.subject

/**
* The VapidKeys object containing the public and private keys for VAPID authentication.
*/
public val vapidKeys: VapidKeys
get() = webPush.vapidKeys

/**
* Sends a push notification using the given endpoint and credentials.
Expand All @@ -29,9 +33,14 @@ public class WebPushService(
* @param urgency The urgency level of the push notification (optional).
*
* @return current state of this subscription
* @throws WebPushStatusException if an unexpected status code is received from the push service.
* @throws WebPushException if an unexpected exception is caught while constructing request.
*/
@Deprecated(message = "Streamlined WebPushService API using a single send method with a Notification object",
ReplaceWith(
expression = "send(Notification(payload, endpoint, p256dh, auth, ttl, topic, urgency))",
imports = ["com.interaso.webpush.Notification"]
)
)
public fun send(
payload: String,
endpoint: String,
Expand All @@ -41,7 +50,7 @@ public class WebPushService(
topic: String? = null,
urgency: WebPush.Urgency? = null,
): WebPush.SubscriptionState {
return send(payload.toByteArray(), endpoint, decodeBase64(p256dh), decodeBase64(auth), ttl, topic, urgency)
return send(Notification(payload, endpoint, p256dh, auth, ttl, topic, urgency))
}

/**
Expand All @@ -56,9 +65,14 @@ public class WebPushService(
* @param urgency The urgency level of the push notification (optional).
*
* @return current state of this subscription
* @throws WebPushStatusException if an unexpected status code is received from the push service.
* @throws WebPushException if an unexpected exception is caught while constructing request.
*/
@Deprecated(message = "Streamlined WebPushService API using a single send method with a Notification object",
ReplaceWith(
expression = "send(Notification(payload, endpoint, p256dh, auth, ttl, topic, urgency))",
imports = ["com.interaso.webpush.Notification"]
)
)
public fun send(
payload: ByteArray,
endpoint: String,
Expand All @@ -68,20 +82,72 @@ public class WebPushService(
topic: String? = null,
urgency: WebPush.Urgency? = null,
): WebPush.SubscriptionState {
val body = webPush.getBody(payload, p256dh, auth)
val headers = webPush.getHeaders(endpoint, ttl, topic, urgency)
return send(Notification(payload, endpoint, p256dh, auth, ttl, topic, urgency))
}

/**
* Sends a push notification using the given endpoint and credentials.
*
* @param notification The web push notification to be sent.
*
* @return current state of this subscription
* @throws WebPushException if an unexpected exception is caught while constructing request.
*/
public abstract fun send(notification: Notification): WebPush.SubscriptionState
}

/**
* Represents a service for sending web push notifications using the built-in JDK [HttpClient].
*/
public class JdkHttpClientWebPushService(
webPush: WebPush,
httpClient: HttpClient? = null
): WebPushService(webPush) {

private val httpClient: HttpClient = httpClient ?: HttpClient.newHttpClient()

public constructor(
subject: String,
vapidKeys: VapidKeys,
httpClient: HttpClient? = null
) : this(WebPush(subject, vapidKeys), httpClient)

public override fun send(notification: Notification): WebPush.SubscriptionState {
val body = webPush.getBody(notification.payload, notification.p256dh, notification.auth)
val headers = webPush.getHeaders(notification.endpoint, notification.ttl, notification.topic, notification.urgency)

val request = HttpRequest.newBuilder()
.POST(HttpRequest.BodyPublishers.ofByteArray(body))
.uri(URI.create(endpoint))
.uri(URI.create(notification.endpoint))
.apply { headers.forEach { setHeader(it.key, it.value) } }
.build()

val response = httpClient.send(request, BodyHandlers.ofString())

return webPush.getSubscriptionState(
response.statusCode(),
response.body(),
)
try {
return webPush.getSubscriptionState(
response.statusCode(),
response.body(),
)
} catch (exception: WebPushException) {
throw WebPushHttpException(
response,
"Received a response with an unsuccessful HTTP status code: [${response.statusCode()}] - ${response.body()}",
exception
)
}
}

/**
* Represents an exception that occurs during web push sending.
*
* @param response The HTTP response object which caused this exception.
* @param message The detail message of the exception.
* @param cause The cause of the exception, or null if the cause is nonexistent or unknown.
*/
public class WebPushHttpException(
public val response: HttpResponse<String>,
message: String,
cause: Throwable
) : WebPushException(message, cause)
}
4 changes: 2 additions & 2 deletions src/test/kotlin/com/interaso/webpush/BrowserTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ class BrowserTest {
"P5GjTLppISlmUyNiZqZi0HNq7GXFniAdcBECNsKBxfI",
)

val webPush = WebPushService("mailto:oss@interaso.com", vapidKeys)
val webPush = JdkHttpClientWebPushService("mailto:oss@interaso.com", vapidKeys)
val notification = "Test"

val server = embeddedServer(CIO, port = 0) {
Expand All @@ -51,7 +51,7 @@ class BrowserTest {
val p256dh: String by params
val auth: String by params

webPush.send(notification, endpoint, p256dh, auth)
webPush.send(Notification(notification, endpoint, p256dh, auth))
call.respondText("OK")
}
}
Expand Down