You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@james.apache.org by bt...@apache.org on 2021/10/29 02:24:26 UTC
[james-project] branch master updated: JAMES-3539 - WebPushClient
(#711)
This is an automated email from the ASF dual-hosted git repository.
btellier pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/james-project.git
The following commit(s) were added to refs/heads/master by this push:
new bbd4c9b JAMES-3539 - WebPushClient (#711)
bbd4c9b is described below
commit bbd4c9b7282a9fceee17fe840915dd0434e4caec
Author: vttran <vt...@linagora.com>
AuthorDate: Fri Oct 29 09:24:17 2021 +0700
JAMES-3539 - WebPushClient (#711)
---
pom.xml | 5 +
.../james/jmap/api/model/PushSubscription.scala | 5 +
server/protocols/jmap-rfc-8621/pom.xml | 19 ++++
.../james/jmap/push_subscription/PushRequest.scala | 92 ++++++++++++++++
.../jmap/push_subscription/WebPushClient.scala | 116 ++++++++++++++++++++
.../DefaultWebPushClientTest.java | 49 +++++++++
.../jmap/push_subscription/PushRequestTest.scala | 73 +++++++++++++
.../push_subscription/PushServerExtension.scala | 82 ++++++++++++++
.../push_subscription/WebPushClientContract.scala | 118 +++++++++++++++++++++
9 files changed, 559 insertions(+)
diff --git a/pom.xml b/pom.xml
index deae6bd..04ec3a3 100644
--- a/pom.xml
+++ b/pom.xml
@@ -2606,6 +2606,11 @@
<version>${junit.vintage.version}</version>
</dependency>
<dependency>
+ <groupId>org.mock-server</groupId>
+ <artifactId>mockserver-netty</artifactId>
+ <version>5.11.2</version>
+ </dependency>
+ <dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>3.7.7</version>
diff --git a/server/data/data-jmap/src/main/scala/org/apache/james/jmap/api/model/PushSubscription.scala b/server/data/data-jmap/src/main/scala/org/apache/james/jmap/api/model/PushSubscription.scala
index 68d45f9..31080f7 100644
--- a/server/data/data-jmap/src/main/scala/org/apache/james/jmap/api/model/PushSubscription.scala
+++ b/server/data/data-jmap/src/main/scala/org/apache/james/jmap/api/model/PushSubscription.scala
@@ -22,6 +22,7 @@ package org.apache.james.jmap.api.model
import java.net.URL
import java.time.{Clock, ZonedDateTime}
import java.util.UUID
+import scala.util.Try
object PushSubscriptionId {
def generate(): PushSubscriptionId = PushSubscriptionId(UUID.randomUUID)
@@ -37,6 +38,10 @@ object VerificationCode {
case class VerificationCode(value: String) extends AnyVal
+object PushSubscriptionServerURL {
+ def from(value: String): Try[PushSubscriptionServerURL] = Try(PushSubscriptionServerURL(new URL(value)))
+}
+
case class PushSubscriptionServerURL(value: URL)
case class PushSubscriptionExpiredTime(value: ZonedDateTime) {
diff --git a/server/protocols/jmap-rfc-8621/pom.xml b/server/protocols/jmap-rfc-8621/pom.xml
index 3d327ea..fc61fe7 100644
--- a/server/protocols/jmap-rfc-8621/pom.xml
+++ b/server/protocols/jmap-rfc-8621/pom.xml
@@ -156,6 +156,25 @@
<scope>test</scope>
</dependency>
<dependency>
+ <groupId>org.mock-server</groupId>
+ <artifactId>mockserver-netty</artifactId>
+ <scope>test</scope>
+ <exclusions>
+ <exclusion>
+ <groupId>io.netty</groupId>
+ <artifactId>netty-common</artifactId>
+ </exclusion>
+ <exclusion>
+ <groupId>io.netty</groupId>
+ <artifactId>netty-transport</artifactId>
+ </exclusion>
+ <exclusion>
+ <groupId>io.netty</groupId>
+ <artifactId>netty-handler</artifactId>
+ </exclusion>
+ </exclusions>
+ </dependency>
+ <dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<scope>test</scope>
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/push_subscription/PushRequest.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/push_subscription/PushRequest.scala
new file mode 100644
index 0000000..159be9a
--- /dev/null
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/push_subscription/PushRequest.scala
@@ -0,0 +1,92 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one *
+ * or more contributor license agreements. See the NOTICE file *
+ * distributed with this work for additional information *
+ * regarding copyright ownership. The ASF licenses this file *
+ * to you under the Apache License, Version 2.0 (the *
+ * "License"); you may not use this file except in compliance *
+ * with the License. You may obtain a copy of the License at *
+ * *
+ * http://www.apache.org/licenses/LICENSE-2.0 *
+ * *
+ * Unless required by applicable law or agreed to in writing, *
+ * software distributed under the License is distributed on an *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY *
+ * KIND, either express or implied. See the License for the *
+ * specific language governing permissions and limitations *
+ * under the License. *
+ ****************************************************************/
+
+package org.apache.james.jmap.push_subscription
+
+import com.google.common.base.CharMatcher
+import eu.timepit.refined
+import eu.timepit.refined.api.{Refined, Validate}
+import org.apache.james.jmap.push_subscription.PushTTL.PushTTL
+import org.apache.james.jmap.push_subscription.PushTopic.PushTopic
+
+object PushUrgency {
+ def default: PushUrgency = Normal
+}
+
+sealed trait PushUrgency {
+ def value: String
+}
+
+case object Low extends PushUrgency {
+ val value: String = "low"
+}
+
+case object VeryLow extends PushUrgency {
+ val value: String = "very-low"
+}
+
+case object Normal extends PushUrgency {
+ val value: String = "normal"
+}
+
+case object High extends PushUrgency {
+ val value: String = "high"
+}
+
+object PushTopic {
+ type PushTopic = String Refined PushTopicConstraint
+ private val charMatcher: CharMatcher = CharMatcher.inRange('a', 'z')
+ .or(CharMatcher.inRange('A', 'Z'))
+ .or(CharMatcher.is('_'))
+ .or(CharMatcher.is('-'))
+ .or(CharMatcher.is('='))
+
+ implicit val validateTopic: Validate.Plain[String, PushTopicConstraint] =
+ Validate.fromPredicate(s => s.nonEmpty && s.length <= 32 && charMatcher.matchesAllOf(s),
+ s => s"'$s' contains some invalid characters. Should use base64 alphabet and be no longer than 32 chars",
+ PushTopicConstraint())
+
+ def validate(string: String): Either[IllegalArgumentException, PushTopic] =
+ refined.refineV[PushTopicConstraint](string)
+ .left
+ .map(new IllegalArgumentException(_))
+}
+
+object PushTTL {
+ type PushTTL = Long Refined PushTTLConstraint
+
+ implicit val validateTTL: Validate.Plain[Long, PushTTLConstraint] =
+ Validate.fromPredicate(s => s >= 0 && s < 2147483648L,
+ s => s"'$s' invalid. Should be non-negative numeric and no greater than 2^31",
+ PushTTLConstraint())
+
+ def validate(value: Long): Either[IllegalArgumentException, PushTTL] =
+ refined.refineV[PushTTLConstraint](value)
+ .left
+ .map(new IllegalArgumentException(_))
+}
+
+case class PushTopicConstraint()
+
+case class PushTTLConstraint()
+
+case class PushRequest(ttl: PushTTL,
+ topic: Option[PushTopic],
+ urgency: Option[PushUrgency],
+ payload: Array[Byte])
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/push_subscription/WebPushClient.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/push_subscription/WebPushClient.scala
new file mode 100644
index 0000000..fee3fd2
--- /dev/null
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/push_subscription/WebPushClient.scala
@@ -0,0 +1,116 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one *
+ * or more contributor license agreements. See the NOTICE file *
+ * distributed with this work for additional information *
+ * regarding copyright ownership. The ASF licenses this file *
+ * to you under the Apache License, Version 2.0 (the *
+ * "License"); you may not use this file except in compliance *
+ * with the License. You may obtain a copy of the License at *
+ * *
+ * http://www.apache.org/licenses/LICENSE-2.0 *
+ * *
+ * Unless required by applicable law or agreed to in writing, *
+ * software distributed under the License is distributed on an *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY *
+ * KIND, either express or implied. See the License for the *
+ * specific language governing permissions and limitations *
+ * under the License. *
+ ****************************************************************/
+
+package org.apache.james.jmap.push_subscription
+
+import io.netty.buffer.Unpooled
+import io.netty.handler.codec.http.HttpResponseStatus
+import org.apache.james.jmap.api.model.PushSubscriptionServerURL
+import org.apache.james.jmap.push_subscription.DefaultWebPushClient.{PUSH_SERVER_ERROR_RESPONSE_MAX_LENGTH, buildHttpClient}
+import org.apache.james.jmap.push_subscription.WebPushClientHeader.{DEFAULT_TIMEOUT, MESSAGE_URGENCY, TIME_TO_LIVE, TOPIC}
+import org.reactivestreams.Publisher
+import reactor.core.publisher.Mono
+import reactor.core.scala.publisher.SMono
+import reactor.netty.ByteBufMono
+import reactor.netty.http.client.{HttpClient, HttpClientResponse}
+import reactor.netty.resources.ConnectionProvider
+
+import java.nio.charset.StandardCharsets
+import java.time.Duration
+import java.time.temporal.ChronoUnit
+
+trait WebPushClient {
+ def push(pushServerUrl: PushSubscriptionServerURL, request: PushRequest): Publisher[Unit]
+}
+
+case class PushClientConfiguration(maxTimeoutSeconds: Option[Int],
+ maxConnections: Option[Int],
+ maxRetryTimes: Option[Int],
+ requestPerSeconds: Option[Int],
+ scheduler: reactor.core.scheduler.Scheduler)
+
+object WebPushClientHeader {
+ val TIME_TO_LIVE: String = "TTL"
+ val MESSAGE_URGENCY: String = "Urgency"
+ val TOPIC: String = "Topic"
+ val DEFAULT_TIMEOUT: Duration = Duration.of(30, ChronoUnit.SECONDS)
+}
+
+sealed abstract class WebPushException(message: String) extends RuntimeException(message)
+
+case class WebPushInvalidRequestException(detailError: String) extends WebPushException(s"Bad request when call to Push Server. ${detailError}")
+
+case class WebPushTemporarilyUnavailableException(detailError: String) extends WebPushException(s"Error when call to Push Server. ${detailError}")
+
+object DefaultWebPushClient {
+ val PUSH_SERVER_ERROR_RESPONSE_MAX_LENGTH: Int = 1024
+
+ private def buildHttpClient(configuration: PushClientConfiguration): HttpClient = {
+ val connectionProviderBuilder: ConnectionProvider.Builder = ConnectionProvider.builder(DefaultWebPushClient.getClass.getName)
+ configuration.maxConnections.foreach(configValue => connectionProviderBuilder.maxConnections(configValue))
+ configuration.maxTimeoutSeconds.foreach(configValue => connectionProviderBuilder.pendingAcquireMaxCount(configValue))
+
+ val responseTimeout: Duration = configuration.maxTimeoutSeconds
+ .map(configValue => Duration.of(configValue, ChronoUnit.SECONDS))
+ .getOrElse(DEFAULT_TIMEOUT)
+
+ HttpClient.create(connectionProviderBuilder.build())
+ .responseTimeout(responseTimeout)
+ .headers(builder => {
+ builder.add("Content-Type", "application/json charset=utf-8")
+ })
+ }
+}
+
+class DefaultWebPushClient(configuration: PushClientConfiguration) extends WebPushClient {
+
+ val httpClient: HttpClient = buildHttpClient(configuration)
+
+ override def push(pushServerUrl: PushSubscriptionServerURL, request: PushRequest): Publisher[Unit] =
+ httpClient
+ .headers(builder => {
+ builder.add(TIME_TO_LIVE, request.ttl.value)
+ builder.add(MESSAGE_URGENCY, request.urgency.getOrElse(PushUrgency.default).value)
+ request.topic.foreach(t => builder.add(TOPIC, t.value))
+ })
+ .post()
+ .uri(pushServerUrl.value.toString)
+ .send(SMono.just(Unpooled.wrappedBuffer(request.payload)))
+ .responseSingle((httpResponse, dataBuf) => afterHTTPResponseHandler(httpResponse, dataBuf))
+ .thenReturn(SMono.empty)
+
+ private def afterHTTPResponseHandler(httpResponse: HttpClientResponse, dataBuf: ByteBufMono): Mono[Void] =
+ Mono.just(httpResponse.status())
+ .flatMap {
+ case HttpResponseStatus.CREATED => Mono.empty()
+ case HttpResponseStatus.BAD_REQUEST => preProcessingData(dataBuf)
+ .flatMap(string => Mono.error(WebPushInvalidRequestException(string)))
+ case _ => preProcessingData(dataBuf)
+ .flatMap(string => Mono.error(WebPushTemporarilyUnavailableException(string)))
+ }.`then`()
+
+ private def preProcessingData(dataBuf: ByteBufMono): Mono[String] =
+ dataBuf.asString(StandardCharsets.UTF_8)
+ .switchIfEmpty(Mono.just(""))
+ .map(content => if (content.length > PUSH_SERVER_ERROR_RESPONSE_MAX_LENGTH) {
+ content.substring(PUSH_SERVER_ERROR_RESPONSE_MAX_LENGTH)
+ } else {
+ content
+ })
+}
diff --git a/server/protocols/jmap-rfc-8621/src/test/java/org/apache/james/jmap/push_subscription/DefaultWebPushClientTest.java b/server/protocols/jmap-rfc-8621/src/test/java/org/apache/james/jmap/push_subscription/DefaultWebPushClientTest.java
new file mode 100644
index 0000000..84a558e
--- /dev/null
+++ b/server/protocols/jmap-rfc-8621/src/test/java/org/apache/james/jmap/push_subscription/DefaultWebPushClientTest.java
@@ -0,0 +1,49 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one *
+ * or more contributor license agreements. See the NOTICE file *
+ * distributed with this work for additional information *
+ * regarding copyright ownership. The ASF licenses this file *
+ * to you under the Apache License, Version 2.0 (the *
+ * "License"); you may not use this file except in compliance *
+ * with the License. You may obtain a copy of the License at *
+ * *
+ * http://www.apache.org/licenses/LICENSE-2.0 *
+ * *
+ * Unless required by applicable law or agreed to in writing, *
+ * software distributed under the License is distributed on an *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY *
+ * KIND, either express or implied. See the License for the *
+ * specific language governing permissions and limitations *
+ * under the License. *
+ ****************************************************************/
+
+package org.apache.james.jmap.push_subscription;
+
+import java.net.URL;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+public class DefaultWebPushClientTest implements WebPushClientContract {
+ @RegisterExtension
+ PushServerExtension pushServerExtension = new PushServerExtension();
+
+ DefaultWebPushClient defaultWebPushClient;
+ URL webPushServerBaseUrl;
+
+ @BeforeEach
+ void beforeEach() {
+ defaultWebPushClient = new DefaultWebPushClient(WebPushClientTestFixture.PUSH_CLIENT_CONFIGURATION());
+ webPushServerBaseUrl = pushServerExtension.getBaseUrl();
+ }
+
+ @Override
+ public WebPushClient testee() {
+ return defaultWebPushClient;
+ }
+
+ @Override
+ public URL pushServerBaseUrl() {
+ return webPushServerBaseUrl;
+ }
+}
diff --git a/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/push_subscription/PushRequestTest.scala b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/push_subscription/PushRequestTest.scala
new file mode 100644
index 0000000..c274aa5
--- /dev/null
+++ b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/push_subscription/PushRequestTest.scala
@@ -0,0 +1,73 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one *
+ * or more contributor license agreements. See the NOTICE file *
+ * distributed with this work for additional information *
+ * regarding copyright ownership. The ASF licenses this file *
+ * to you under the Apache License, Version 2.0 (the *
+ * "License"); you may not use this file except in compliance *
+ * with the License. You may obtain a copy of the License at *
+ * *
+ * http://www.apache.org/licenses/LICENSE-2.0 *
+ * *
+ * Unless required by applicable law or agreed to in writing, *
+ * software distributed under the License is distributed on an *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY *
+ * KIND, either express or implied. See the License for the *
+ * specific language governing permissions and limitations *
+ * under the License. *
+ ****************************************************************/
+
+package org.apache.james.jmap.push_subscription
+
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.params.ParameterizedTest
+import org.junit.jupiter.params.provider.ValueSource
+
+class PushRequestTest {
+
+ @Test
+ def ttlShouldNotAcceptNegativeNumber(): Unit = {
+ assertThat(PushTTL.validate(-1).isRight)
+ .isFalse
+ }
+
+ @Test
+ def ttlShouldAcceptZeroNumber(): Unit = {
+ assertThat(PushTTL.validate(0).isRight)
+ .isTrue
+ }
+
+ @Test
+ def ttlShouldAcceptPositiveNumber(): Unit = {
+ assertThat(PushTTL.validate(1).isRight)
+ .isTrue
+ }
+
+ @Test
+ def ttlShouldLimitMaximumNumber(): Unit = {
+ assertThat(PushTTL.validate(2147483648L).isRight)
+ .isFalse
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = Array("/", "@", "#", "$", "%", "^", "&", "*", "(", ")",
+ "{", "}", ":", ";", "?", "<", ">", ".",
+ "1", "9"))
+ def topicShouldNotAcceptSpecialCharacters(value: String): Unit = {
+ assertThat(PushTopic.validate(value).isRight)
+ .isFalse
+ }
+
+ @Test
+ def topicShouldAcceptAlphabetValue(): Unit = {
+ assertThat(PushTopic.validate("value").isRight)
+ .isTrue
+ }
+
+ @Test
+ def topicShouldLimitLength(): Unit = {
+ assertThat(PushTopic.validate("v".repeat(33)).isRight)
+ .isFalse
+ }
+}
diff --git a/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/push_subscription/PushServerExtension.scala b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/push_subscription/PushServerExtension.scala
new file mode 100644
index 0000000..489bbba
--- /dev/null
+++ b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/push_subscription/PushServerExtension.scala
@@ -0,0 +1,82 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one *
+ * or more contributor license agreements. See the NOTICE file *
+ * distributed with this work for additional information *
+ * regarding copyright ownership. The ASF licenses this file *
+ * to you under the Apache License, Version 2.0 (the *
+ * "License"); you may not use this file except in compliance *
+ * with the License. You may obtain a copy of the License at *
+ * *
+ * http://www.apache.org/licenses/LICENSE-2.0 *
+ * *
+ * Unless required by applicable law or agreed to in writing, *
+ * software distributed under the License is distributed on an *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY *
+ * KIND, either express or implied. See the License for the *
+ * specific language governing permissions and limitations *
+ * under the License. *
+ ****************************************************************/
+
+package org.apache.james.jmap.push_subscription
+
+import org.junit.jupiter.api.extension.{AfterEachCallback, BeforeEachCallback, ExtensionContext, ParameterContext, ParameterResolver}
+import org.mockserver.configuration.ConfigurationProperties
+import org.mockserver.integration.ClientAndServer
+import org.mockserver.integration.ClientAndServer.startClientAndServer
+import org.mockserver.model.HttpRequest.request
+import org.mockserver.model.HttpResponse.response
+import org.mockserver.model.NottableString.{not, string}
+
+import java.net.URL
+import java.time.Clock
+import java.util.UUID
+
+class PushServerExtension extends BeforeEachCallback with AfterEachCallback with ParameterResolver {
+ var mockServer: ClientAndServer = _
+
+ override def afterEach(extensionContext: ExtensionContext): Unit = mockServer.close()
+
+ override def supportsParameter(parameterContext: ParameterContext, extensionContext: ExtensionContext): Boolean =
+ parameterContext.getParameter.getType eq classOf[ClientAndServer]
+
+ override def resolveParameter(parameterContext: ParameterContext, extensionContext: ExtensionContext): AnyRef =
+ mockServer
+
+ override def beforeEach(extensionContext: ExtensionContext): Unit = {
+ mockServer = startClientAndServer(0)
+ ConfigurationProperties.logLevel("WARN")
+ MockPushServer.appendSpec(mockServer)
+ }
+
+ def getBaseUrl: URL = new URL(s"http://127.0.0.1:${mockServer.getLocalPort}")
+}
+
+object MockPushServer {
+ def appendSpec(mockServer: ClientAndServer): Unit = {
+ mockServer
+ .when(request.withHeader(not("TTL")))
+ .respond(response.withStatusCode(400).withBody("missing TTL header"))
+
+ mockServer
+ .when(request.withHeader(not("Content-type")))
+ .respond(response.withStatusCode(400).withBody("Content-type is missing or invalid"))
+
+ mockServer
+ .when(request.withHeader(string("Content-type"), not("application/json charset=utf-8")))
+ .respond(response.withStatusCode(400).withBody("Content-type is missing or invalid"))
+
+ mockServer
+ .when(request
+ .withPath("/push")
+ .withMethod("POST")
+ .withHeader(string("Content-type"), string("application/json charset=utf-8"))
+ .withHeader(string("Urgency"))
+ .withHeader(string("Topic"))
+ .withHeader(string("TTL")))
+ .respond(response
+ .withStatusCode(201)
+ .withHeader("Location", String.format("https://push.example.net/message/%s", UUID.randomUUID))
+ .withHeader("Date", Clock.systemUTC.toString)
+ .withBody(UUID.randomUUID.toString))
+ }
+}
diff --git a/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/push_subscription/WebPushClientContract.scala b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/push_subscription/WebPushClientContract.scala
new file mode 100644
index 0000000..dad63de
--- /dev/null
+++ b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/push_subscription/WebPushClientContract.scala
@@ -0,0 +1,118 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one *
+ * or more contributor license agreements. See the NOTICE file *
+ * distributed with this work for additional information *
+ * regarding copyright ownership. The ASF licenses this file *
+ * to you under the Apache License, Version 2.0 (the *
+ * "License"); you may not use this file except in compliance *
+ * with the License. You may obtain a copy of the License at *
+ * *
+ * http://www.apache.org/licenses/LICENSE-2.0 *
+ * *
+ * Unless required by applicable law or agreed to in writing, *
+ * software distributed under the License is distributed on an *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY *
+ * KIND, either express or implied. See the License for the *
+ * specific language governing permissions and limitations *
+ * under the License. *
+ ****************************************************************/
+
+package org.apache.james.jmap.push_subscription
+
+import org.apache.james.jmap.api.model.PushSubscriptionServerURL
+import org.apache.james.jmap.push_subscription.WebPushClientTestFixture.PUSH_REQUEST_SAMPLE
+import org.assertj.core.api.Assertions.{assertThatCode, assertThatThrownBy}
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.params.ParameterizedTest
+import org.junit.jupiter.params.provider.ValueSource
+import org.mockserver.integration.ClientAndServer
+import org.mockserver.model.HttpRequest.request
+import org.mockserver.model.HttpResponse.response
+import org.mockserver.verify.VerificationTimes
+import reactor.core.scala.publisher.SMono
+import reactor.core.scheduler.Schedulers
+
+import java.net.URL
+import java.nio.charset.StandardCharsets
+
+object WebPushClientTestFixture {
+ val PUSH_CLIENT_CONFIGURATION: PushClientConfiguration =
+ PushClientConfiguration(
+ maxTimeoutSeconds = Some(10),
+ maxConnections = Some(10),
+ maxRetryTimes = Some(10),
+ requestPerSeconds = Some(10),
+ scheduler = Schedulers.elastic())
+
+ val PUSH_REQUEST_SAMPLE: PushRequest = PushRequest(
+ ttl = PushTTL.validate(15).toOption.get,
+ topic = PushTopic.validate("topicabc").toOption,
+ urgency = Some(High),
+ payload = "Content123".getBytes(StandardCharsets.UTF_8))
+}
+
+trait WebPushClientContract {
+ def testee: WebPushClient
+
+ def pushServerBaseUrl: URL
+
+ private def getPushSubscriptionServerURL: PushSubscriptionServerURL =
+ PushSubscriptionServerURL.from(s"${pushServerBaseUrl.toString}/push").get
+
+ @Test
+ def pushValidRequestShouldNotThrowException(): Unit = {
+ assertThatCode(() => SMono.fromPublisher(testee.push(getPushSubscriptionServerURL, PUSH_REQUEST_SAMPLE))
+ .block())
+ .doesNotThrowAnyException()
+ }
+
+ @Test
+ def pushValidRequestShouldHTTPCallToPushServer(pushServer: ClientAndServer): Unit = {
+ SMono.fromPublisher(testee.push(getPushSubscriptionServerURL, PUSH_REQUEST_SAMPLE))
+ .block()
+ pushServer.verify(request()
+ .withPath("/push")
+ .withHeader("TTL", "15")
+ .withHeader("Topic", "topicabc")
+ .withHeader("Urgency", "High")
+ .withBody(new String(PUSH_REQUEST_SAMPLE.payload, StandardCharsets.UTF_8)),
+ VerificationTimes.atLeast(1))
+ }
+
+ @ParameterizedTest
+ @ValueSource(ints = Array(500, 501, 502, 503, 504))
+ def pushRequestShouldThrowWhenPushServerReturnFailHTTPStatus(httpErrorCode: Int, pushServer: ClientAndServer): Unit = {
+ pushServer
+ .when(request
+ .withPath("/invalid"))
+ .respond(response.withStatusCode(httpErrorCode))
+
+ assertThatThrownBy(() => SMono.fromPublisher(
+ testee.push(PushSubscriptionServerURL.from(s"${pushServerBaseUrl.toString}/invalid").get,
+ PUSH_REQUEST_SAMPLE))
+ .block())
+ .isInstanceOf(classOf[WebPushTemporarilyUnavailableException])
+
+ pushServer.verify(request()
+ .withPath("/invalid"),
+ VerificationTimes.atLeast(1))
+ }
+
+ @Test
+ def pushRequestShouldParserErrorResponseFromPushServerWhenFail(pushServer: ClientAndServer): Unit = {
+ pushServer
+ .when(request
+ .withPath("/invalid"))
+ .respond(response
+ .withStatusCode(500)
+ .withBody("Request did not validate 123"))
+
+ assertThatThrownBy(() => SMono.fromPublisher(
+ testee.push(PushSubscriptionServerURL.from(s"${pushServerBaseUrl.toString}/invalid").get,
+ PUSH_REQUEST_SAMPLE))
+ .block())
+ .hasMessageContaining("Request did not validate 123")
+ }
+}
+
+
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@james.apache.org
For additional commands, e-mail: notifications-help@james.apache.org