You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@james.apache.org by rc...@apache.org on 2021/10/28 10:32:46 UTC

[james-project] 01/01: WIP JAMES-3539 PushSubscription/set create

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 <rc...@linagora.com>
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: notifications-unsubscribe@james.apache.org
For additional commands, e-mail: notifications-help@james.apache.org