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]
