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 2020/09/03 03:23:54 UTC
[james-project] 12/13: JAMES-3365 Back-reference resolution at the
JSON level
This is an automated email from the ASF dual-hosted git repository.
rcordier pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/james-project.git
commit 4c992a5b8f4cec706026243d76c2b917c54b99be
Author: Benoit Tellier <bt...@linagora.com>
AuthorDate: Mon Aug 24 15:51:55 2020 +0700
JAMES-3365 Back-reference resolution at the JSON level
---
.../rfc8621/contract/BackReferenceContract.scala | 403 +++++++++++++++++++++
.../rfc8621/memory/MemoryBackReferenceTest.java} | 31 +-
.../org/apache/james/jmap/json/Serializer.scala | 10 +-
.../org/apache/james/jmap/model/Invocation.scala | 4 +
.../apache/james/jmap/model/ProblemDetails.scala | 1 +
.../apache/james/jmap/routes/JMAPApiRoutes.scala | 20 +-
.../james/jmap/routes/ProcessingContext.scala | 142 +++++++-
.../apache/james/jmap/json/BackReferenceTest.scala | 160 ++++++++
8 files changed, 751 insertions(+), 20 deletions(-)
diff --git a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/BackReferenceContract.scala b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/BackReferenceContract.scala
new file mode 100644
index 0000000..6d18ddf
--- /dev/null
+++ b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/BackReferenceContract.scala
@@ -0,0 +1,403 @@
+/****************************************************************
+ * 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.rfc8621.contract
+
+import io.netty.handler.codec.http.HttpHeaderNames.ACCEPT
+import io.restassured.RestAssured._
+import net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson
+import org.apache.http.HttpStatus.SC_OK
+import org.apache.james.GuiceJamesServer
+import org.apache.james.jmap.http.UserCredential
+import org.apache.james.jmap.rfc8621.contract.Fixture._
+import org.apache.james.utils.DataProbeImpl
+import org.junit.jupiter.api.{BeforeEach, Test}
+
+trait BackReferenceContract {
+ @BeforeEach
+ def setUp(server: GuiceJamesServer): Unit = {
+ server.getProbe(classOf[DataProbeImpl])
+ .fluent
+ .addDomain(DOMAIN.asString)
+ .addUser(BOB.asString, BOB_PASSWORD)
+ .addUser(ANDRE.asString, ANDRE_PASSWORD)
+
+ requestSpecification = baseRequestSpecBuilder(server)
+ .setAuth(authScheme(UserCredential(BOB, BOB_PASSWORD)))
+ .build
+ }
+
+ @Test
+ def backReferenceResolvingShouldWork(): Unit = {
+ val response = `given`
+ .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+ .body(
+ s"""{
+ | "using": [
+ | "urn:ietf:params:jmap:core",
+ | "urn:ietf:params:jmap:mail"],
+ | "methodCalls": [[
+ | "Mailbox/get",
+ | {
+ | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+ | "ids": null,
+ | "properties": ["id"]
+ | },
+ | "c1"],[
+ | "Mailbox/get",
+ | {
+ | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+ | "properties": ["id"],
+ | "#ids": {
+ | "resultOf":"c1",
+ | "name":"Mailbox/get",
+ | "path":"list/*/id"
+ | }
+ | },
+ | "c2"]]
+ |}""".stripMargin)
+ .when
+ .post
+ .`then`
+ .statusCode(SC_OK)
+ .extract()
+ .body()
+ .asString()
+
+ assertThatJson(response).isEqualTo(
+ s"""{
+ | "sessionState": "75128aab4b1b",
+ | "methodResponses": [
+ | [
+ | "Mailbox/get",
+ | {
+ | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+ | "state": "000001",
+ | "list": [
+ | {"id": "1"},
+ | {"id": "5"},
+ | {"id": "2"},
+ | {"id": "3"},
+ | {"id": "4"},
+ | {"id": "6"}
+ | ],
+ | "notFound": []
+ | },
+ | "c1"
+ | ],
+ | [
+ | "Mailbox/get",
+ | {
+ | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+ | "state": "000001",
+ | "list": [
+ | {"id": "1"},
+ | {"id": "5"},
+ | {"id": "2"},
+ | {"id": "3"},
+ | {"id": "4"},
+ | {"id": "6"}
+ | ],
+ | "notFound": []
+ | },
+ | "c2"
+ | ]
+ | ]
+ |}""".stripMargin)
+ }
+
+ @Test
+ def pathShouldBeResolvable(): Unit = {
+ val response = `given`
+ .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+ .body(
+ s"""{
+ | "using": [
+ | "urn:ietf:params:jmap:core",
+ | "urn:ietf:params:jmap:mail"],
+ | "methodCalls": [[
+ | "Mailbox/get",
+ | {
+ | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+ | "ids": null,
+ | "properties": ["id"]
+ | },
+ | "c1"],[
+ | "Mailbox/get",
+ | {
+ | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+ | "properties": ["id"],
+ | "#ids": {
+ | "resultOf":"c1",
+ | "name":"Mailbox/get",
+ | "path":"unknown/*/id"
+ | }
+ | },
+ | "c2"]]
+ |}""".stripMargin)
+ .when
+ .post.prettyPeek()
+ .`then`
+ .statusCode(SC_OK)
+ .extract()
+ .body()
+ .asString()
+
+ assertThatJson(response).isEqualTo(
+ s"""{
+ | "sessionState": "75128aab4b1b",
+ | "methodResponses": [
+ | [
+ | "Mailbox/get",
+ | {
+ | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+ | "state": "000001",
+ | "list": [
+ | {"id": "1"},
+ | {"id": "5"},
+ | {"id": "2"},
+ | {"id": "3"},
+ | {"id": "4"},
+ | {"id": "6"}
+ | ],
+ | "notFound": []
+ | },
+ | "c1"
+ | ],
+ | [
+ | "error",
+ | {
+ | "type": "invalidResultReference",
+ | "description": "Failed resolving back-reference: List((,List(JsonValidationError(List(Expected path unknown was missing),ArraySeq()))))"
+ | },
+ | "c2"
+ | ]
+ | ]
+ |}""".stripMargin)
+ }
+
+ @Test
+ def wildcardRequiresAnArray(): Unit = {
+ val response = `given`
+ .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+ .body(
+ s"""{
+ | "using": [
+ | "urn:ietf:params:jmap:core",
+ | "urn:ietf:params:jmap:mail"],
+ | "methodCalls": [[
+ | "Mailbox/get",
+ | {
+ | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+ | "ids": null,
+ | "properties": ["id"]
+ | },
+ | "c1"],[
+ | "Mailbox/get",
+ | {
+ | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+ | "properties": ["id"],
+ | "#ids": {
+ | "resultOf":"c1",
+ | "name":"Mailbox/get",
+ | "path":"*/*/id"
+ | }
+ | },
+ | "c2"]]
+ |}""".stripMargin)
+ .when
+ .post.prettyPeek()
+ .`then`
+ .statusCode(SC_OK)
+ .extract()
+ .body()
+ .asString()
+
+ assertThatJson(response).isEqualTo(
+ s"""{
+ | "sessionState": "75128aab4b1b",
+ | "methodResponses": [
+ | [
+ | "Mailbox/get",
+ | {
+ | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+ | "state": "000001",
+ | "list": [
+ | {"id": "1"},
+ | {"id": "5"},
+ | {"id": "2"},
+ | {"id": "3"},
+ | {"id": "4"},
+ | {"id": "6"}
+ | ],
+ | "notFound": []
+ | },
+ | "c1"
+ | ],
+ | [
+ | "error",
+ | {
+ | "type": "invalidResultReference",
+ | "description": "Failed resolving back-reference: List((,List(JsonValidationError(List(Expecting an array),ArraySeq()))))"
+ | },
+ | "c2"
+ | ]
+ | ]
+ |}""".stripMargin)
+ }
+
+ @Test
+ def resolvedBackReferenceShouldHaveTheRightMethodName(): Unit = {
+ val response = `given`
+ .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+ .body(
+ s"""{
+ | "using": [
+ | "urn:ietf:params:jmap:core",
+ | "urn:ietf:params:jmap:mail"],
+ | "methodCalls": [[
+ | "Mailbox/get",
+ | {
+ | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+ | "ids": null,
+ | "properties": ["id"]
+ | },
+ | "c1"],[
+ | "Mailbox/get",
+ | {
+ | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+ | "properties": ["id"],
+ | "#ids": {
+ | "resultOf":"c1",
+ | "name":"Mailbox/set",
+ | "path":"list/*/id"
+ | }
+ | },
+ | "c2"]]
+ |}""".stripMargin)
+ .when
+ .post.prettyPeek()
+ .`then`
+ .statusCode(SC_OK)
+ .extract()
+ .body()
+ .asString()
+
+ assertThatJson(response).isEqualTo(
+ s"""{
+ | "sessionState": "75128aab4b1b",
+ | "methodResponses": [
+ | [
+ | "Mailbox/get",
+ | {
+ | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+ | "state": "000001",
+ | "list": [
+ | {"id": "1"},
+ | {"id": "5"},
+ | {"id": "2"},
+ | {"id": "3"},
+ | {"id": "4"},
+ | {"id": "6"}
+ | ],
+ | "notFound": []
+ | },
+ | "c1"
+ | ],
+ | [
+ | "error",
+ | {
+ | "type": "invalidResultReference",
+ | "description": "Failed resolving back-reference: List((,List(JsonValidationError(List(MethodCallId(c1) references a MethodName(Mailbox/get) method),ArraySeq()))))"
+ | },
+ | "c2"
+ | ]
+ | ]
+ |}""".stripMargin)
+ }
+
+ @Test
+ def resolvingAnUnexistingMethodCallIdShouldFail(): Unit = {
+ val response = `given`
+ .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+ .body(
+ s"""{
+ | "using": [
+ | "urn:ietf:params:jmap:core",
+ | "urn:ietf:params:jmap:mail"],
+ | "methodCalls": [[
+ | "Mailbox/get",
+ | {
+ | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+ | "ids": null,
+ | "properties": ["id"]
+ | },
+ | "c1"],[
+ | "Mailbox/get",
+ | {
+ | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+ | "properties": ["id"],
+ | "#ids": {
+ | "resultOf":"c42",
+ | "name":"Mailbox/get",
+ | "path":"list/*/id"
+ | }
+ | },
+ | "c2"]]
+ |}""".stripMargin)
+ .when
+ .post.prettyPeek()
+ .`then`
+ .statusCode(SC_OK)
+ .extract()
+ .body()
+ .asString()
+
+ assertThatJson(response).isEqualTo(
+ s"""{
+ | "sessionState": "75128aab4b1b",
+ | "methodResponses": [
+ | [
+ | "Mailbox/get",
+ | {
+ | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+ | "state": "000001",
+ | "list": [
+ | {"id": "1"},
+ | {"id": "5"},
+ | {"id": "2"},
+ | {"id": "3"},
+ | {"id": "4"},
+ | {"id": "6"}
+ | ],
+ | "notFound": []
+ | },
+ | "c1"
+ | ],
+ | [
+ | "error",
+ | {
+ | "type": "invalidResultReference",
+ | "description": "Failed resolving back-reference: List((,List(JsonValidationError(List(Back reference could not be resolved),ArraySeq()))))"
+ | },
+ | "c2"
+ | ]
+ | ]
+ |}""".stripMargin)
+ }
+}
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/model/ProblemDetails.scala b/server/protocols/jmap-rfc-8621-integration-tests/memory-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/memory/MemoryBackReferenceTest.java
similarity index 55%
copy from server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/model/ProblemDetails.scala
copy to server/protocols/jmap-rfc-8621-integration-tests/memory-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/memory/MemoryBackReferenceTest.java
index 4bfb65c..5be999a 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/model/ProblemDetails.scala
+++ b/server/protocols/jmap-rfc-8621-integration-tests/memory-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/memory/MemoryBackReferenceTest.java
@@ -16,22 +16,23 @@
* specific language governing permissions and limitations *
* under the License. *
****************************************************************/
-package org.apache.james.jmap.model
-import eu.timepit.refined.auto._
-import org.apache.http.HttpStatus.SC_BAD_REQUEST
-import org.apache.james.jmap.model.RequestLevelErrorType.ErrorTypeIdentifier
-import org.apache.james.jmap.model.StatusCode.ErrorStatus
+package org.apache.james.jmap.rfc8621.memory;
-/**
- * Problem Details for HTTP APIs within the JMAP context
- * https://tools.ietf.org/html/rfc7807
- * see https://jmap.io/spec-core.html#errors
- */
-case class ProblemDetails(`type`: ErrorTypeIdentifier, status: ErrorStatus, limit: Option[String], detail: String)
+import static org.apache.james.MemoryJamesServerMain.IN_MEMORY_SERVER_AGGREGATE_MODULE;
-object ProblemDetails {
- def notRequestProblem(message: String): ProblemDetails = ProblemDetails(RequestLevelErrorType.NOT_REQUEST, SC_BAD_REQUEST, None, message)
- def notJSONProblem(message: String): ProblemDetails = ProblemDetails(RequestLevelErrorType.NOT_JSON, SC_BAD_REQUEST, None, message)
- def unknownCapabilityProblem(message: String): ProblemDetails = ProblemDetails(RequestLevelErrorType.UNKNOWN_CAPABILITY, SC_BAD_REQUEST, None, message)
+import org.apache.james.GuiceJamesServer;
+import org.apache.james.JamesServerBuilder;
+import org.apache.james.JamesServerExtension;
+import org.apache.james.jmap.rfc8621.contract.BackReferenceContract;
+import org.apache.james.modules.TestJMAPServerModule;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+public class MemoryBackReferenceTest implements BackReferenceContract {
+ @RegisterExtension
+ static JamesServerExtension testExtension = new JamesServerBuilder<>(JamesServerBuilder.defaultConfigurationProvider())
+ .server(configuration -> GuiceJamesServer.forConfiguration(configuration)
+ .combineWith(IN_MEMORY_SERVER_AGGREGATE_MODULE)
+ .overrideWith(new TestJMAPServerModule()))
+ .build();
}
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/Serializer.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/Serializer.scala
index 17591a5..f93fc8e 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/Serializer.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/Serializer.scala
@@ -35,6 +35,7 @@ import org.apache.james.jmap.model.CapabilityIdentifier.CapabilityIdentifier
import org.apache.james.jmap.model.Invocation.{Arguments, MethodCallId, MethodName}
import org.apache.james.jmap.model.SetError.SetErrorDescription
import org.apache.james.jmap.model.{Account, Invocation, Session, _}
+import org.apache.james.jmap.routes.{BackReference, JsonPath}
import org.apache.james.mailbox.Role
import org.apache.james.mailbox.model.MailboxACL.{Right => JavaRight}
import org.apache.james.mailbox.model.{MailboxACL, MailboxId}
@@ -231,8 +232,12 @@ class Serializer @Inject() (mailboxIdFactory: MailboxId.Factory) {
private implicit val idsRead: Reads[Ids] = Json.valueReads[Ids]
private implicit val propertiesRead: Reads[Properties] = Json.valueReads[Properties]
+ private implicit val jsonPathReadRead: Reads[JsonPath] = {
+ case JsString(path) => JsSuccess(JsonPath.parse(path))
+ case _ => JsError("JsonPath objects are represented by JsonString")
+ }
private implicit val mailboxGetRequest: Reads[MailboxGetRequest] = Json.reads[MailboxGetRequest]
-
+ private implicit val backReferenceReads: Reads[BackReference] = Json.reads[BackReference]
private implicit val mailboxRemoveEmailsOnDestroy: Reads[RemoveEmailsOnDestroy] = Json.valueFormat[RemoveEmailsOnDestroy]
implicit val mailboxCreationRequest: Reads[MailboxCreationRequest] = Json.reads[MailboxCreationRequest]
@@ -390,4 +395,7 @@ class Serializer @Inject() (mailboxIdFactory: MailboxId.Factory) {
def deserializeVacationResponseGetRequest(input: String): JsResult[VacationResponseGetRequest] = Json.parse(input).validate[VacationResponseGetRequest]
def deserializeVacationResponseGetRequest(input: JsValue): JsResult[VacationResponseGetRequest] = Json.fromJson[VacationResponseGetRequest](input)
+
+ def deserializeBackReference(input: JsValue): JsResult[BackReference] = Json.fromJson[BackReference](input)
+
}
\ No newline at end of file
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/model/Invocation.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/model/Invocation.scala
index fd75472..15a3cc2 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/model/Invocation.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/model/Invocation.scala
@@ -65,4 +65,8 @@ object ErrorCode {
case object AccountNotFound extends ErrorCode {
override def code: String = "accountNotFound"
}
+
+ case object InvalidResultReference extends ErrorCode {
+ override def code: String = "invalidResultReference"
+ }
}
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/model/ProblemDetails.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/model/ProblemDetails.scala
index 4bfb65c..4e8df2d 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/model/ProblemDetails.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/model/ProblemDetails.scala
@@ -34,4 +34,5 @@ object ProblemDetails {
def notRequestProblem(message: String): ProblemDetails = ProblemDetails(RequestLevelErrorType.NOT_REQUEST, SC_BAD_REQUEST, None, message)
def notJSONProblem(message: String): ProblemDetails = ProblemDetails(RequestLevelErrorType.NOT_JSON, SC_BAD_REQUEST, None, message)
def unknownCapabilityProblem(message: String): ProblemDetails = ProblemDetails(RequestLevelErrorType.UNKNOWN_CAPABILITY, SC_BAD_REQUEST, None, message)
+ def invalidResultReference(message: String): ProblemDetails = ProblemDetails(RequestLevelErrorType.UNKNOWN_CAPABILITY, SC_BAD_REQUEST, None, message)
}
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/routes/JMAPApiRoutes.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/routes/JMAPApiRoutes.scala
index a3dc83f..4f4683a 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/routes/JMAPApiRoutes.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/routes/JMAPApiRoutes.scala
@@ -114,13 +114,14 @@ class JMAPApiRoutes (val authenticator: Authenticator,
mailboxSession: MailboxSession): SMono[Void] = {
val processingContext: ProcessingContext = new ProcessingContext
val unsupportedCapabilities = requestObject.using.toSet -- DefaultCapabilities.SUPPORTED.ids
+ val capabilities: Set[CapabilityIdentifier] = requestObject.using.toSet
if (unsupportedCapabilities.nonEmpty) {
SMono.raiseError(UnsupportedCapabilitiesException(unsupportedCapabilities))
} else {
requestObject
.methodCalls
- .map(invocation => this.processMethodWithMatchName(requestObject.using.toSet, invocation, mailboxSession, processingContext))
+ .map(invocation => process(capabilities, mailboxSession, processingContext, invocation))
.foldLeft(SFlux.empty[Invocation]) { (flux: SFlux[Invocation], mono: SMono[Invocation]) => flux.concatWith(mono) }
.collectSeq()
.flatMap((invocations: Seq[Invocation]) =>
@@ -129,12 +130,25 @@ class JMAPApiRoutes (val authenticator: Authenticator,
.sendString(
SMono.fromCallable(() =>
serializer.serialize(ResponseObject(ResponseObject.SESSION_STATE, invocations)).toString),
- StandardCharsets.UTF_8
- ).`then`())
+ StandardCharsets.UTF_8)
+ .`then`())
)
}
}
+ private def process(capabilities: Set[CapabilityIdentifier], mailboxSession: MailboxSession, processingContext: ProcessingContext, invocation: Invocation) = {
+ SMono.defer(() => {
+ processingContext.resolveBackReferences(serializer, invocation) match {
+ case Left(e) => SMono.just(Invocation.error(
+ errorCode = ErrorCode.InvalidResultReference,
+ description = s"Failed resolving back-reference: ${e.message}",
+ methodCallId = invocation.methodCallId))
+ case Right(resolvedInvocation) => processMethodWithMatchName(capabilities, resolvedInvocation, mailboxSession, processingContext)
+ .doOnNext(invocationResult => processingContext.recordInvocation(invocationResult))
+ }
+ })
+ }
+
private def processMethodWithMatchName(capabilities: Set[CapabilityIdentifier], invocation: Invocation, mailboxSession: MailboxSession, processingContext: ProcessingContext): SMono[Invocation] =
SMono.justOrEmpty(methodsByName.get(invocation.methodName))
.flatMap(method => SMono.fromPublisher(method.process(capabilities, invocation, mailboxSession, processingContext)))
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/routes/ProcessingContext.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/routes/ProcessingContext.scala
index c216048..64072d8 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/routes/ProcessingContext.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/routes/ProcessingContext.scala
@@ -19,20 +19,160 @@
package org.apache.james.jmap.routes
+import org.apache.james.jmap.json.Serializer
import org.apache.james.jmap.mail.MailboxSetRequest.UnparsedMailboxId
import org.apache.james.jmap.mail.VacationResponse.{UnparsedVacationResponseId, VACATION_RESPONSE_ID}
import org.apache.james.jmap.model.Id.Id
-import org.apache.james.jmap.model.{ClientId, Id, ServerId}
+import org.apache.james.jmap.model.Invocation.{Arguments, MethodCallId, MethodName}
+import org.apache.james.jmap.model.{ClientId, Id, Invocation, ServerId}
import org.apache.james.mailbox.model.MailboxId
+import play.api.libs.json.{JsArray, JsError, JsObject, JsResult, JsSuccess, JsValue, Reads}
import scala.collection.mutable
+sealed trait JsonPathPart
+
+case class WildcardPart() extends JsonPathPart
+
+case class PlainPart(name: String) extends JsonPathPart {
+ def read(jsValue: JsValue): JsResult[JsValue] = jsValue match {
+ case JsObject(underlying) => underlying.get(name).map(JsSuccess(_)).getOrElse(JsError(s"Expected path $name was missing"))
+ case _ => JsError("Expecting a JsObject but got a different structure")
+ }
+}
+
+object JsonPath {
+ def parse(string: String): JsonPath = JsonPath(string.split('/').toList
+ .flatMap {
+ case "" => None
+ case "*" => Some(WildcardPart())
+ case part => Some(PlainPart(part))
+ })
+}
+
+case class JsonPath(parts: List[JsonPathPart]) {
+ def evaluate(jsValue: JsValue): JsResult[JsValue] = parts match {
+ case Nil => JsSuccess(jsValue)
+ case head :: tail =>
+ val tailAsJsonPath = JsonPath(tail)
+ head match {
+ case part: PlainPart => part.read(jsValue).flatMap(subPart => tailAsJsonPath.evaluate(subPart))
+ case _: WildcardPart => tailAsJsonPath.readWildcard(jsValue)
+ }
+ }
+
+ private def readWildcard(jsValue: JsValue) = jsValue match {
+ case JsArray(arrayItems) =>
+ val evaluationResults: List[JsResult[JsValue]] = arrayItems.toList.map(evaluate)
+
+ evaluationResults.find(x => x.isInstanceOf[JsError])
+ .getOrElse(JsSuccess(expendArray(evaluationResults)))
+ case _ => JsError("Expecting an array")
+ }
+
+ private def expendArray(evaluationResults: List[JsResult[JsValue]]): JsArray = {
+ JsArray(evaluationResults
+ .map(_.get)
+ .flatMap({
+ case JsArray(nestedArray) => nestedArray
+ case other: JsValue => List(other)
+ }))
+ }
+}
+
+case class BackReference(name: MethodName, path: JsonPath, resultOf: MethodCallId) {
+ def resolve(invocation: Invocation): JsResult[JsValue] = if (!(invocation.methodName equals name)) {
+ JsError(s"$resultOf references a ${invocation.methodName} method")
+ } else {
+ path.evaluate(invocation.arguments.value)
+ }
+}
+
+case class InvalidResultReferenceException(message: String) extends IllegalArgumentException
+
class ProcessingContext {
private val creationIds: mutable.Map[ClientId, ServerId] = mutable.Map()
+ private val invocations: mutable.Map[MethodCallId, Invocation] = mutable.Map()
def recordCreatedId(clientId: ClientId, serverId: ServerId): Unit = creationIds.put(clientId, serverId)
private def retrieveServerId(clientId: ClientId): Option[ServerId] = creationIds.get(clientId)
+ def recordInvocation(invocation: Invocation): Unit = invocations.put(invocation.methodCallId, invocation)
+
+ def resolveBackReferences(serializer: Serializer, invocation: Invocation): Either[InvalidResultReferenceException, Invocation] =
+ backReferenceResolver(serializer).reads(invocation.arguments.value) match {
+ case JsError(e) => Left(InvalidResultReferenceException(e.toString()))
+ case JsSuccess(JsObject(underlying), _) => Right(Invocation(methodName = invocation.methodName,
+ methodCallId = invocation.methodCallId,
+ arguments = Arguments(JsObject(underlying))))
+ case others: JsSuccess[JsValue] => Left(InvalidResultReferenceException(s"Unexpected value $others"))
+ }
+
+ private def backReferenceResolver(serializer: Serializer): Reads[JsValue] = {
+ case JsArray(value) => resolveBackReferences(serializer, value)
+ case JsObject(underlying) => resolveBackReference(serializer, underlying)
+ case others: JsValue => JsSuccess(others)
+ }
+
+ private def resolveBackReferences(serializer: Serializer, array: collection.IndexedSeq[JsValue]): JsResult[JsValue] = {
+ val resolver: Reads[JsValue] = backReferenceResolver(serializer)
+ val results: Seq[JsResult[JsValue]] = array.map(resolver.reads).toSeq
+ results.find(_.isError)
+ .getOrElse(JsSuccess(JsArray(results.map(_.get))))
+ }
+
+ private def resolveBackReference(serializer: Serializer, underlying: collection.Map[String, JsValue]): JsResult[JsObject] = {
+ val resolutions = underlying.map(resolveBackReference(serializer))
+
+ val firstError = resolutions.flatMap({
+ case Left(jsError) => Some(jsError)
+ case _ => None
+ }).headOption
+
+ val transformedMap = resolutions.flatMap({
+ case Right((entry, value)) => Some((entry, value))
+ case _ => None
+ }).toMap
+
+ firstError.getOrElse(JsSuccess(JsObject(transformedMap)))
+ }
+
+ private def resolveBackReference(serializer: Serializer)(entry: (String, JsValue)): Either[JsError, (String, JsValue)] = {
+ if (entry._1.startsWith("#")) {
+ val newEntry: String = entry._1.substring(1)
+
+ serializer.deserializeBackReference(entry._2) match {
+ case JsSuccess(backReference, _) => resolveBackReference(newEntry, backReference)
+ // If the JSON object is not a back-reference continue parsing (it could be a creationId)
+ case JsError(_) => propagateBackReferenceResolution(serializer, entry)
+ }
+ } else {
+ propagateBackReferenceResolution(serializer, entry)
+ }
+ }
+
+ private def resolveBackReference(newEntry: String, backReference: BackReference): Either[JsError, (String, JsValue)] = {
+ resolve(backReference) match {
+ case JsError(e) => Left(JsError(e))
+ case JsSuccess(resolvedBackReference, _) => Right((newEntry, resolvedBackReference))
+ }
+ }
+
+ private def propagateBackReferenceResolution(serializer: Serializer, entry: (String, JsValue)): Either[JsError, (String, JsValue)] = {
+ val entryPayload: JsResult[JsValue] = backReferenceResolver(serializer).reads(entry._2)
+
+ entryPayload match {
+ case JsError(e) => Left(JsError(e))
+ case JsSuccess(newValue, _) => Right((entry._1, newValue))
+ }
+ }
+
+ private def retrieveInvocation(callId: MethodCallId): Option[Invocation] = invocations.get(callId)
+
+ private def resolve(backReference: BackReference): JsResult[JsValue] = retrieveInvocation(backReference.resultOf)
+ .map(backReference.resolve)
+ .getOrElse(JsError("Back reference could not be resolved"))
+
def resolveMailboxId(unparsedMailboxId: UnparsedMailboxId, mailboxIdFactory: MailboxId.Factory): Either[IllegalArgumentException, MailboxId] =
Id.validate(unparsedMailboxId.value)
.flatMap(id => resolveServerId(ClientId(id)))
diff --git a/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/json/BackReferenceTest.scala b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/json/BackReferenceTest.scala
new file mode 100644
index 0000000..f51c5fc
--- /dev/null
+++ b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/json/BackReferenceTest.scala
@@ -0,0 +1,160 @@
+/****************************************************************
+ * 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.auto._
+import org.apache.james.jmap.model.Invocation.{MethodCallId, MethodName}
+import org.apache.james.jmap.routes.{BackReference, JsonPath}
+import org.apache.james.mailbox.model.TestId
+import org.scalatest.matchers.should.Matchers
+import org.scalatest.wordspec.AnyWordSpec
+import play.api.libs.json.{JsError, JsSuccess, Json}
+
+
+object BackReferenceTest {
+ val SERIALIZER: Serializer = new Serializer(new TestId.Factory)
+}
+
+class BackReferenceTest extends AnyWordSpec with Matchers {
+ "Deserialize backReference" should {
+ "succeed" in {
+ val jsonPath = JsonPath.parse("list/*/id")
+ val expectedBackReference = BackReference(
+ name = MethodName("Mailbox/get"),
+ resultOf = MethodCallId("c1"),
+ path = jsonPath)
+
+ BackReferenceTest.SERIALIZER.deserializeBackReference(
+ Json.parse("""{
+ | "resultOf":"c1",
+ | "name":"Mailbox/get",
+ | "path":"list/*/id"
+ |}""".stripMargin)) should equal(JsSuccess(expectedBackReference))
+ }
+ }
+
+ "JsonPath evaluation" should {
+ "noop when empty" in {
+ val jsonPath = JsonPath.parse("")
+ val json = Json.parse("""["array"]""")
+ val expected = Json.parse("""["array"]""")
+
+ jsonPath.evaluate(json) should equal(JsSuccess(expected))
+ }
+ "succeed when single path part is present" in {
+ val jsonPath = JsonPath.parse("path")
+ val json = Json.parse("""{"path" : ["array"]}""")
+ val expected = Json.parse("""["array"]""")
+
+ jsonPath.evaluate(json) should equal(JsSuccess(expected))
+ }
+ "succeed when array part is present" in {
+ val jsonPath = JsonPath.parse("path/*")
+ val json = Json.parse("""{"path" : ["1", "2"]}""")
+ val expected = Json.parse("""["1", "2"]""")
+
+ jsonPath.evaluate(json) should equal(JsSuccess(expected))
+ }
+ "fail when not an array" in {
+ val jsonPath = JsonPath.parse("path/*")
+ val json = Json.parse("""{"path" : {"key": "value"}}""")
+
+ jsonPath.evaluate(json) shouldBe a[JsError]
+ }
+ "allow simple resolution within an array" in {
+ val jsonPath = JsonPath.parse("path/*/key")
+ val json = Json.parse(
+ """{"path" : [
+ | {"key": "1"},
+ | {"key": "2"}
+ |]}""".stripMargin)
+ val expected = Json.parse("""["1", "2"]""")
+
+ jsonPath.evaluate(json) should equal(JsSuccess(expected))
+ }
+ "allow recursive array resolution" in {
+ val jsonPath = JsonPath.parse("path/*/key/*")
+ val json = Json.parse(
+ """{"path" : [
+ | {"key": ["1", "2"]},
+ | {"key": ["3"]}
+ |]}""".stripMargin)
+ val expected = Json.parse("""["1", "2", "3"]""")
+
+ jsonPath.evaluate(json) should equal(JsSuccess(expected))
+ }
+ "succeed when double path part is present" in {
+ val jsonPath = JsonPath.parse("path/second")
+ val json = Json.parse(
+ """{
+ | "path" : {
+ | "second" : {"key": "value"}
+ | }
+ |}""".stripMargin)
+ val expected = Json.parse("""{"key": "value"}""")
+
+ jsonPath.evaluate(json) should equal(JsSuccess(expected))
+ }
+ "succeed double delimiter" in {
+ val jsonPath = JsonPath.parse("path//second")
+ val json = Json.parse(
+ """{
+ | "path" : {
+ | "second" : {"key": "value"}
+ | }
+ |}""".stripMargin)
+ val expected = Json.parse("""{"key": "value"}""")
+
+ jsonPath.evaluate(json) should equal(JsSuccess(expected))
+ }
+ "accept starting delimiter" in {
+ val jsonPath = JsonPath.parse("/path")
+ val json = Json.parse("""{"path" : ["array"]}""")
+ val expected = Json.parse("""["array"]""")
+
+ jsonPath.evaluate(json) should equal(JsSuccess(expected))
+ }
+ "accept ending delimiter" in {
+ val jsonPath = JsonPath.parse("path/")
+ val json = Json.parse("""{"path" : ["array"]}""")
+ val expected = Json.parse("""["array"]""")
+
+ jsonPath.evaluate(json) should equal(JsSuccess(expected))
+ }
+ "fail when single path part is missing" in {
+ val jsonPath = JsonPath.parse("other")
+ val json = Json.parse("""{"path" : ["array"]}""")
+
+ jsonPath.evaluate(json) shouldBe a[JsError]
+ }
+ "fail when single path part is present in an array" in {
+ val jsonPath = JsonPath.parse("other")
+ val json = Json.parse("""[{"path" : "array"}]""")
+
+ jsonPath.evaluate(json) shouldBe a[JsError]
+ }
+ "fail when single path part requested on a value" in {
+ val jsonPath = JsonPath.parse("other")
+ val json = Json.parse(""""value"""")
+
+ jsonPath.evaluate(json) shouldBe a[JsError]
+ }
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@james.apache.org
For additional commands, e-mail: notifications-help@james.apache.org