This is an automated email from the ASF dual-hosted git repository.

rcordier pushed a commit to branch pushsub-set-create
in repository https://gitbox.apache.org/repos/asf/james-project.git

commit 1c0a657d213e515aa44a9c23ef9cf0de036ffd73
Author: Rene Cordier <[email protected]>
AuthorDate: Thu Oct 28 17:32:25 2021 +0700

    WIP JAMES-3539 PushSubscription/set create
---
 .../james/jmap/core/PushSubscriptionSet.scala      | 65 +++++++++++++++++
 .../jmap/json/PushSubscriptionSerializer.scala     | 59 +++++++++++++++
 .../PushSubscriptionSetCreatePerformer.scala       | 81 +++++++++++++++++++++
 .../jmap/method/PushSubscriptionSetMethod.scala    | 84 ++++++++++++++++++++++
 4 files changed, 289 insertions(+)

diff --git 
a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/PushSubscriptionSet.scala
 
b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/PushSubscriptionSet.scala
new file mode 100644
index 0000000..386bfe4
--- /dev/null
+++ 
b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/PushSubscriptionSet.scala
@@ -0,0 +1,65 @@
+/****************************************************************
+ * 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.core
+
+import eu.timepit.refined.collection.NonEmpty
+import eu.timepit.refined.refineV
+import eu.timepit.refined.types.string.NonEmptyString
+import org.apache.james.jmap.api.model.{PushSubscriptionExpiredTime, 
PushSubscriptionId}
+import org.apache.james.jmap.core.Id.Id
+import org.apache.james.jmap.core.SetError.SetErrorDescription
+import play.api.libs.json.JsObject
+
+case class PushSubscriptionSetRequest(create: 
Option[Map[PushSubscriptionCreationId, JsObject]])
+
+case class PushSubscriptionCreationId(id: Id)
+
+object PushSubscriptionCreation {
+  private val serverSetProperty = Set("id", "verificationCode")
+  private val assignableProperties = Set("deviceClientId", "url", "keys", 
"expires", "types")
+  private val knownProperties = assignableProperties ++ serverSetProperty
+
+  def validateProperties(jsObject: JsObject): 
Either[PushSubscriptionCreationParseException, JsObject] =
+    (jsObject.keys.intersect(serverSetProperty), 
jsObject.keys.diff(knownProperties)) match {
+      case (_, unknownProperties) if unknownProperties.nonEmpty =>
+        Left(PushSubscriptionCreationParseException(SetError.invalidArguments(
+          SetErrorDescription("Some unknown properties were specified"),
+          Some(toProperties(unknownProperties.toSet)))))
+      case (specifiedServerSetProperties, _) if 
specifiedServerSetProperties.nonEmpty =>
+        Left(PushSubscriptionCreationParseException(SetError.invalidArguments(
+          SetErrorDescription("Some server-set properties were specified"),
+          Some(toProperties(specifiedServerSetProperties.toSet)))))
+      case _ => scala.Right(jsObject)
+    }
+
+  private def toProperties(strings: Set[String]): Properties = 
Properties(strings
+    .flatMap(string => {
+      val refinedValue: Either[String, NonEmptyString] = 
refineV[NonEmpty](string)
+      refinedValue.fold(_ => None,  Some(_))
+    }))
+}
+
+case class PushSubscriptionCreationParseException(setError: SetError) extends 
Exception
+
+case class PushSubscriptionCreationResponse(id: PushSubscriptionId,
+                                            expires: 
Option[PushSubscriptionExpiredTime])
+
+case class PushSubscriptionSetResponse(created: 
Option[Map[PushSubscriptionCreationId, PushSubscriptionCreationResponse]],
+                                       notCreated: 
Option[Map[PushSubscriptionCreationId, SetError]])
diff --git 
a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/PushSubscriptionSerializer.scala
 
b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/PushSubscriptionSerializer.scala
new file mode 100644
index 0000000..632fcf7
--- /dev/null
+++ 
b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/PushSubscriptionSerializer.scala
@@ -0,0 +1,59 @@
+/****************************************************************
+ * 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.json
+
+import eu.timepit.refined.refineV
+import org.apache.james.jmap.api.model.{DeviceClientId, 
PushSubscriptionCreationRequest, PushSubscriptionExpiredTime, 
PushSubscriptionId, PushSubscriptionKeys, PushSubscriptionServerURL, TypeName}
+import org.apache.james.jmap.core.Id.IdConstraint
+import org.apache.james.jmap.core.{PushSubscriptionCreationId, 
PushSubscriptionCreationResponse, PushSubscriptionSetRequest, 
PushSubscriptionSetResponse, SetError}
+import play.api.libs.json.{Format, JsError, JsObject, JsResult, JsSuccess, 
JsValue, Json, OWrites, Reads, Writes}
+
+class PushSubscriptionSerializer {
+  private implicit val pushSubscriptionIdWrites: Writes[PushSubscriptionId] = 
Json.valueWrites[PushSubscriptionId]
+
+  private implicit val pushSubscriptionExpiredTimeFormat: 
Format[PushSubscriptionExpiredTime] = 
Json.valueFormat[PushSubscriptionExpiredTime]
+  private implicit val deviceClientIdReads: Reads[DeviceClientId] = 
Json.valueReads[DeviceClientId]
+  private implicit val pushSubscriptionServerURLReads: 
Reads[PushSubscriptionServerURL] = Json.valueReads[PushSubscriptionServerURL]
+  private implicit val pushSubscriptionKeysReads: Reads[PushSubscriptionKeys] 
= Json.valueReads[PushSubscriptionKeys]
+  private implicit val typeNameReads: Reads[TypeName] = 
Json.valueReads[TypeName]
+
+  implicit val pushSubscriptionCreationRequest: 
Reads[PushSubscriptionCreationRequest] = 
Json.reads[PushSubscriptionCreationRequest]
+
+  private implicit val mapCreationRequestByPushSubscriptionCreationId: 
Reads[Map[PushSubscriptionCreationId, JsObject]] =
+    Reads.mapReads[PushSubscriptionCreationId, JsObject] {string => 
refineV[IdConstraint](string)
+      .fold(e => JsError(s"mailbox creationId needs to match id contraints: 
$e"),
+        id => JsSuccess(PushSubscriptionCreationId(id))) }
+
+  private implicit val pushSubscriptionSetRequestReads: 
Reads[PushSubscriptionSetRequest] = Json.reads[PushSubscriptionSetRequest]
+
+  private implicit val pushSubscriptionCreationResponseWrites: 
Writes[PushSubscriptionCreationResponse] = 
Json.writes[PushSubscriptionCreationResponse]
+
+  private implicit val pushSubscriptionMapSetErrorForCreationWrites: 
Writes[Map[PushSubscriptionCreationId, SetError]] =
+    mapWrites[PushSubscriptionCreationId, SetError](_.id.value, setErrorWrites)
+
+  private implicit val pushSubscriptionMapCreationResponseWrites: 
Writes[Map[PushSubscriptionCreationId, PushSubscriptionCreationResponse]] =
+    mapWrites[PushSubscriptionCreationId, 
PushSubscriptionCreationResponse](_.id.value, 
pushSubscriptionCreationResponseWrites)
+
+  private implicit val emailResponseSetWrites: 
OWrites[PushSubscriptionSetResponse] = Json.writes[PushSubscriptionSetResponse]
+
+  def deserializePushSubscriptionSetRequest(input: JsValue): 
JsResult[PushSubscriptionSetRequest] = 
Json.fromJson[PushSubscriptionSetRequest](input)
+
+  def serialize(response: PushSubscriptionSetResponse): JsObject = 
Json.toJsObject(response)
+}
diff --git 
a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/PushSubscriptionSetCreatePerformer.scala
 
b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/PushSubscriptionSetCreatePerformer.scala
new file mode 100644
index 0000000..5a21dbc
--- /dev/null
+++ 
b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/PushSubscriptionSetCreatePerformer.scala
@@ -0,0 +1,81 @@
+package org.apache.james.jmap.method
+
+import org.apache.james.jmap.api.model.{PushSubscriptionCreationRequest, 
PushSubscriptionExpiredTime}
+import org.apache.james.jmap.api.pushsubscription.PushSubscriptionRepository
+import org.apache.james.jmap.core.SetError.SetErrorDescription
+import org.apache.james.jmap.core.{PushSubscriptionCreation, 
PushSubscriptionCreationId, PushSubscriptionCreationParseException, 
PushSubscriptionCreationResponse, PushSubscriptionSetRequest, SetError}
+import org.apache.james.jmap.json.PushSubscriptionSerializer
+import 
org.apache.james.jmap.method.PushSubscriptionSetCreatePerformer.{CreationFailure,
 CreationResult, CreationResults, CreationSuccess}
+import org.apache.james.mailbox.MailboxSession
+import play.api.libs.json.{JsError, JsObject, JsPath, JsSuccess, Json, 
JsonValidationError}
+import reactor.core.scala.publisher.{SFlux, SMono}
+import reactor.core.scheduler.Schedulers
+
+import javax.inject.Inject
+
+object PushSubscriptionSetCreatePerformer {
+  trait CreationResult
+  case class CreationSuccess(clientId: PushSubscriptionCreationId, response: 
PushSubscriptionCreationResponse) extends CreationResult
+  case class CreationFailure(clientId: PushSubscriptionCreationId, e: 
Throwable) extends CreationResult {
+    def asMessageSetError: SetError = e match {
+      case e: IllegalArgumentException => 
SetError.invalidArguments(SetErrorDescription(e.getMessage))
+      case _ => SetError.serverFail(SetErrorDescription(e.getMessage))
+    }
+  }
+
+  case class CreationResults(results: Seq[CreationResult]) {
+    def created: Option[Map[PushSubscriptionCreationId, 
PushSubscriptionCreationResponse]] =
+      Option(results.flatMap{
+        case result: CreationSuccess => Some((result.clientId, 
result.response))
+        case _ => None
+      }.toMap)
+        .filter(_.nonEmpty)
+
+    def notCreated: Option[Map[PushSubscriptionCreationId, SetError]] = {
+      Option(results.flatMap{
+        case failure: CreationFailure => Some((failure.clientId, 
failure.asMessageSetError))
+        case _ => None
+      }
+        .toMap)
+        .filter(_.nonEmpty)
+    }
+  }
+}
+
+class PushSubscriptionSetCreatePerformer @Inject()(serializer: 
PushSubscriptionSerializer,
+                                                   pushSubscriptionRepository: 
PushSubscriptionRepository) {
+  def create(request: PushSubscriptionSetRequest, mailboxSession: 
MailboxSession): SMono[CreationResults] =
+    SFlux.fromIterable(request.create.getOrElse(Map()))
+      .concatMap {
+        case (clientId, json) => parseCreate(json)
+          .fold(e => SMono.just[CreationResult](CreationFailure(clientId, new 
IllegalArgumentException(e.toString))),
+            creationRequest => create(clientId, creationRequest, 
mailboxSession))
+      }.collectSeq()
+      .map(CreationResults)
+
+  private def parseCreate(jsObject: JsObject): 
Either[PushSubscriptionCreationParseException, PushSubscriptionCreationRequest] 
=
+    PushSubscriptionCreation.validateProperties(jsObject)
+      .flatMap(validJsObject => 
Json.fromJson(validJsObject)(serializer.pushSubscriptionCreationRequest) match {
+        case JsSuccess(creationRequest, _) => Right(creationRequest)
+        case JsError(errors) => 
Left(PushSubscriptionCreationParseException(pushSubscriptionSetError(errors)))
+      })
+
+  private def create(clientId: PushSubscriptionCreationId, request: 
PushSubscriptionCreationRequest, mailboxSession: MailboxSession): 
SMono[CreationResult] =
+    
SMono.fromPublisher(pushSubscriptionRepository.save(mailboxSession.getUser, 
request))
+      .map(subscription => CreationSuccess(clientId, 
PushSubscriptionCreationResponse(subscription.id, 
showExpires(subscription.expires, request))))
+      .onErrorResume(e => SMono.just[CreationResult](CreationFailure(clientId, 
e)))
+      .subscribeOn(Schedulers.elastic)
+
+  private def showExpires(expires: PushSubscriptionExpiredTime, request: 
PushSubscriptionCreationRequest): Option[PushSubscriptionExpiredTime] = 
request.expires match {
+    case Some(requestExpires) if expires.eq(requestExpires) => None
+    case _ => Some(expires)
+  }
+
+  private def pushSubscriptionSetError(errors: collection.Seq[(JsPath, 
collection.Seq[JsonValidationError])]): SetError =
+    errors.head match {
+      case (path, Seq()) => 
SetError.invalidArguments(SetErrorDescription(s"'$path' property in 
PushSubscription object is not valid"))
+      case (path, Seq(JsonValidationError(Seq("error.path.missing")))) => 
SetError.invalidArguments(SetErrorDescription(s"Missing '$path' property in 
PushSubscription object"))
+      case (path, Seq(JsonValidationError(Seq(message)))) => 
SetError.invalidArguments(SetErrorDescription(s"'$path' property in 
PushSubscription object is not valid: $message"))
+      case (path, _) => 
SetError.invalidArguments(SetErrorDescription(s"Unknown error on property 
'$path'"))
+    }
+}
diff --git 
a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/PushSubscriptionSetMethod.scala
 
b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/PushSubscriptionSetMethod.scala
new file mode 100644
index 0000000..693225b
--- /dev/null
+++ 
b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/PushSubscriptionSetMethod.scala
@@ -0,0 +1,84 @@
+/****************************************************************
+ * 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.method
+
+import eu.timepit.refined.auto._
+import org.apache.james.jmap.core.CapabilityIdentifier.{CapabilityIdentifier, 
JMAP_CORE}
+import org.apache.james.jmap.core.Invocation.{Arguments, MethodName}
+import org.apache.james.jmap.core.{ClientId, ErrorCode, Id, Invocation, 
PushSubscriptionSetRequest, PushSubscriptionSetResponse, ServerId}
+import org.apache.james.jmap.json.{PushSubscriptionSerializer, 
ResponseSerializer}
+import org.apache.james.jmap.mail.{RequestTooLargeException, 
UnsupportedNestingException, UnsupportedRequestParameterException}
+import org.apache.james.mailbox.MailboxSession
+import org.apache.james.metrics.api.MetricFactory
+import play.api.libs.json.{JsError, JsSuccess}
+import reactor.core.scala.publisher.{SFlux, SMono}
+
+import javax.inject.Inject
+
+class PushSubscriptionSetMethod @Inject()(serializer: 
PushSubscriptionSerializer,
+                                          createPerformer: 
PushSubscriptionSetCreatePerformer,
+                                          val metricFactory: MetricFactory) 
extends Method {
+  override val methodName: Invocation.MethodName = 
MethodName("PushSubscription/set")
+  override val requiredCapabilities: Set[CapabilityIdentifier] = Set(JMAP_CORE)
+
+  override def process(capabilities: Set[CapabilityIdentifier], invocation: 
InvocationWithContext, mailboxSession: MailboxSession): 
SMono[InvocationWithContext] = {
+    val either: Either[Exception, SMono[InvocationWithContext]] = for {
+      request <- getRequest(invocation.invocation)
+    } yield {
+      doProcess(invocation, mailboxSession, request)
+    }
+
+    val result: SFlux[InvocationWithContext] = 
SFlux.fromPublisher(either.fold(e => SFlux.error[InvocationWithContext](e), r 
=> r))
+      .onErrorResume[InvocationWithContext] {
+        case e: UnsupportedRequestParameterException => 
SFlux.just[InvocationWithContext] (InvocationWithContext(Invocation.error(
+          ErrorCode.InvalidArguments,
+          s"The following parameter ${e.unsupportedParam} is syntactically 
valid, but is not supported by the server.",
+          invocation.invocation.methodCallId), invocation.processingContext))
+        case e: UnsupportedNestingException => 
SFlux.just[InvocationWithContext] (InvocationWithContext(Invocation.error(
+          ErrorCode.UnsupportedFilter,
+          description = e.message,
+          invocation.invocation.methodCallId), invocation.processingContext))
+        case e: IllegalArgumentException => SFlux.just[InvocationWithContext] 
(InvocationWithContext(Invocation.error(ErrorCode.InvalidArguments, 
e.getMessage, invocation.invocation.methodCallId), 
invocation.processingContext))
+        case e: RequestTooLargeException => SFlux.just[InvocationWithContext] 
(InvocationWithContext(Invocation.error(ErrorCode.RequestTooLarge, 
e.description, invocation.invocation.methodCallId), 
invocation.processingContext))
+        case e: Throwable => SFlux.error[InvocationWithContext] (e)
+      }
+
+    
SMono.fromPublisher(metricFactory.decoratePublisherWithTimerMetric(JMAP_RFC8621_PREFIX
 + methodName.value, result))
+  }
+
+  private def getRequest(invocation: Invocation): 
Either[IllegalArgumentException, PushSubscriptionSetRequest] =
+    
serializer.deserializePushSubscriptionSetRequest(invocation.arguments.value) 
match {
+      case JsSuccess(emailSetRequest, _) => Right(emailSetRequest)
+      case errors: JsError => Left(new 
IllegalArgumentException(ResponseSerializer.serialize(errors).toString))
+    }
+
+  def doProcess(invocation: InvocationWithContext, mailboxSession: 
MailboxSession, request: PushSubscriptionSetRequest): 
SMono[InvocationWithContext] =
+    for {
+      created <- createPerformer.create(request, mailboxSession)
+    } yield InvocationWithContext(
+      invocation = Invocation(
+        methodName = methodName,
+        arguments = Arguments(serializer.serialize(PushSubscriptionSetResponse(
+          created = created.created,
+          notCreated = created.notCreated))),
+        methodCallId = invocation.invocation.methodCallId),
+      processingContext = invocation.processingContext
+    )
+}

---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to