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