You are viewing a plain text version of this content. The canonical link for it is here.
Posted to server-dev@james.apache.org by bt...@apache.org on 2020/04/13 02:53:31 UTC

[james-project] 04/13: JAMES-2891 JMAP Session Routes

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

btellier pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/james-project.git

commit e2357d4ae94d46bf047a94522058c4d022eb6b3d
Author: Tran Tien Duc <dt...@linagora.com>
AuthorDate: Fri Mar 27 11:50:26 2020 +0700

    JAMES-2891 JMAP Session Routes
---
 server/protocols/jmap-rfc-8621/pom.xml             |  23 +++
 .../org/apache/james/jmap/http/SessionRoutes.scala |  75 ++++++++
 .../apache/james/jmap/http/SessionSupplier.scala   | 108 ++++++++++++
 .../apache/james/jmap/http/SessionRoutesTest.scala | 194 +++++++++++++++++++++
 .../james/jmap/http/SessionSupplierTest.scala      |  57 ++++++
 5 files changed, 457 insertions(+)

diff --git a/server/protocols/jmap-rfc-8621/pom.xml b/server/protocols/jmap-rfc-8621/pom.xml
index 86f6c0a..79dfe41 100644
--- a/server/protocols/jmap-rfc-8621/pom.xml
+++ b/server/protocols/jmap-rfc-8621/pom.xml
@@ -38,6 +38,10 @@
         </dependency>
         <dependency>
             <groupId>${james.groupId}</groupId>
+            <artifactId>james-server-jmap</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>${james.groupId}</groupId>
             <artifactId>testing-base</artifactId>
             <scope>test</scope>
         </dependency>
@@ -61,6 +65,21 @@
             <version>0.9.13</version>
         </dependency>
         <dependency>
+            <groupId>io.projectreactor</groupId>
+            <artifactId>reactor-scala-extensions_${scala.base}</artifactId>
+            <version>0.5.1</version>
+        </dependency>
+        <dependency>
+            <groupId>io.rest-assured</groupId>
+            <artifactId>rest-assured</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-core</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
             <groupId>org.scalatest</groupId>
             <artifactId>scalatest_${scala.base}</artifactId>
             <version>3.1.1</version>
@@ -70,6 +89,10 @@
             <groupId>org.scala-lang.modules</groupId>
             <artifactId>scala-java8-compat_${scala.base}</artifactId>
         </dependency>
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>jcl-over-slf4j</artifactId>
+        </dependency>
     </dependencies>
 
     <build>
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/http/SessionRoutes.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/http/SessionRoutes.scala
new file mode 100644
index 0000000..79bfb45
--- /dev/null
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/http/SessionRoutes.scala
@@ -0,0 +1,75 @@
+/** **************************************************************
+ * 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.http
+
+import java.util.function.BiFunction
+
+import io.netty.handler.codec.http.HttpHeaderNames.CONTENT_TYPE
+import io.netty.handler.codec.http.HttpResponseStatus.OK
+import javax.inject.Inject
+import org.apache.james.jmap.HttpConstants.JSON_CONTENT_TYPE_UTF8
+import org.apache.james.jmap.JMAPRoutes
+import org.apache.james.jmap.exceptions.UnauthorizedException
+import org.apache.james.jmap.http.SessionRoutes.JMAP_SESSION
+import org.apache.james.jmap.json.Serializer
+import org.apache.james.jmap.model.Session
+import org.reactivestreams.Publisher
+import org.slf4j.{Logger, LoggerFactory}
+import play.api.libs.json.Json
+import reactor.core.publisher.Mono
+import reactor.core.scala.publisher.SMono
+import reactor.core.scheduler.Schedulers
+import reactor.netty.http.server.{HttpServerRequest, HttpServerResponse, HttpServerRoutes}
+
+object SessionRoutes {
+  private val JMAP_SESSION = "/jmap/session"
+  private val LOGGER = LoggerFactory.getLogger(classOf[SessionRoutes])
+}
+
+@Inject
+class SessionRoutes(val authFilter: Authenticator,
+                    val sessionSupplier: SessionSupplier = new SessionSupplier(),
+                    val serializer: Serializer = new Serializer) extends JMAPRoutes {
+
+  val logger: Logger = SessionRoutes.LOGGER
+  val generateSession: BiFunction[HttpServerRequest, HttpServerResponse, Publisher[Void]] =
+    (request, response) => SMono.fromPublisher(authFilter.authenticate(request))
+      .map(_.getUser)
+      .flatMap(sessionSupplier.generate)
+      .flatMap(session => sendRespond(session, response))
+      .onErrorResume(throwable => SMono.fromPublisher(errorHandling(throwable, response)))
+      .subscribeOn(Schedulers.elastic())
+
+  override def define(builder: HttpServerRoutes): HttpServerRoutes = {
+    builder.get(JMAP_SESSION, generateSession)
+  }
+
+  private def sendRespond(session: Session, resp: HttpServerResponse): SMono[Void] =
+    SMono.fromPublisher(resp.header(CONTENT_TYPE, JSON_CONTENT_TYPE_UTF8)
+      .status(OK)
+      .sendString(SMono.fromCallable(() => Json.stringify(serializer.serialize(session))))
+      .`then`())
+
+  def errorHandling(throwable: Throwable, response: HttpServerResponse): Mono[Void] =
+    throwable match {
+      case _: UnauthorizedException => handleAuthenticationFailure(response, throwable)
+      case _ => handleInternalError(response, throwable)
+    }
+}
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/http/SessionSupplier.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/http/SessionSupplier.scala
new file mode 100644
index 0000000..c7fb076
--- /dev/null
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/http/SessionSupplier.scala
@@ -0,0 +1,108 @@
+/** **************************************************************
+ * 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.http
+
+import java.net.URL
+
+import com.google.common.annotations.VisibleForTesting
+import eu.timepit.refined.auto._
+import eu.timepit.refined.refineV
+import org.apache.james.core.Username
+import org.apache.james.jmap.http.SessionSupplier.{CORE_CAPABILITY, MAIL_CAPABILITY}
+import org.apache.james.jmap.model.CapabilityIdentifier.CapabilityIdentifier
+import org.apache.james.jmap.model.Id.Id
+import org.apache.james.jmap.model._
+import reactor.core.publisher.Mono
+import reactor.core.scala.publisher.{SFlux, SMono}
+
+object SessionSupplier {
+  private val CORE_CAPABILITY = CoreCapability(
+    properties = CoreCapabilityProperties(
+      MaxSizeUpload(10_000_000L),
+      MaxConcurrentUpload(4L),
+      MaxSizeRequest(10_000_000L),
+      MaxConcurrentRequests(4L),
+      MaxCallsInRequest(16L),
+      MaxObjectsInGet(500L),
+      MaxObjectsInSet(500L),
+      collationAlgorithms = List("i;unicode-casemap")))
+
+  private val MAIL_CAPABILITY = MailCapability(
+    properties = MailCapabilityProperties(
+      MaxMailboxesPerEmail(Some(10_000_000L)),
+      MaxMailboxDepth(None),
+      MaxSizeMailboxName(200L),
+      MaxSizeAttachmentsPerEmail(20_000_000L),
+      emailQuerySortOptions = List("receivedAt", "cc", "from", "to", "subject", "size", "sentAt", "hasKeyword", "uid", "Id"),
+      MayCreateTopLevelMailbox(true)
+    ))
+}
+
+class SessionSupplier {
+  def generate(username: Username): SMono[Session] =
+    SMono.fromPublisher(
+      Mono.zip(
+        accounts(username).asJava(),
+        primaryAccounts(username).asJava()))
+      .map(tuple => generate(username, tuple.getT1, tuple.getT2))
+
+  private def accounts(username: Username): SMono[Map[Id, Account]] =
+    getId(username)
+      .map(id => Map(
+        id -> Account(
+          username,
+          IsPersonal(true),
+          IsReadOnly(false),
+          accountCapabilities = Set(CORE_CAPABILITY, MAIL_CAPABILITY))))
+
+  private def primaryAccounts(username: Username): SMono[Map[CapabilityIdentifier, Id]] =
+    SFlux.just(CORE_CAPABILITY, MAIL_CAPABILITY)
+      .flatMap(capability => getId(username)
+        .map(id => (capability.identifier, id)))
+      .collectMap(getIdentifier, getId)
+  private def getIdentifier(tuple : (CapabilityIdentifier, Id)): CapabilityIdentifier = tuple._1
+  private def getId(tuple : (CapabilityIdentifier, Id)): Id = tuple._2
+
+  private def getId(username: Username): SMono[Id] = {
+    SMono.fromCallable(() => refineId(username))
+      .flatMap {
+        case Left(errorMessage: String) => SMono.raiseError(new IllegalStateException(errorMessage))
+        case Right(id) => SMono.just(id)
+      }
+  }
+
+  private def refineId(username: Username): Either[String, Id] = refineV(usernameHashCode(username))
+  @VisibleForTesting def usernameHashCode(username: Username) = username.asString().hashCode.toOctalString
+
+  private def generate(username: Username,
+                       accounts: Map[Id, Account],
+                       primaryAccounts: Map[CapabilityIdentifier, Id]): Session = {
+    Session(
+      Capabilities(CORE_CAPABILITY, MAIL_CAPABILITY),
+      accounts,
+      primaryAccounts,
+      username,
+      apiUrl = new URL("http://this-url-is-hardcoded.org/jmap"),
+      downloadUrl = new URL("http://this-url-is-hardcoded.org/download"),
+      uploadUrl = new URL("http://this-url-is-hardcoded.org/upload"),
+      eventSourceUrl = new URL("http://this-url-is-hardcoded.org/eventSource"),
+      state = "000001")
+  }
+}
diff --git a/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/http/SessionRoutesTest.scala b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/http/SessionRoutesTest.scala
new file mode 100644
index 0000000..6ff561d
--- /dev/null
+++ b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/http/SessionRoutesTest.scala
@@ -0,0 +1,194 @@
+/** **************************************************************
+ * 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.http
+
+import java.nio.charset.StandardCharsets
+
+import io.restassured.RestAssured
+import io.restassured.builder.RequestSpecBuilder
+import io.restassured.config.EncoderConfig.encoderConfig
+import io.restassured.config.RestAssuredConfig.newConfig
+import io.restassured.http.ContentType
+import org.apache.http.HttpStatus
+import org.apache.james.core.Username
+import org.apache.james.jmap.http.SessionRoutesTest.{BOB, TEST_CONFIGURATION}
+import org.apache.james.jmap.{JMAPConfiguration, JMAPRoutes, JMAPServer}
+import org.apache.james.mailbox.MailboxSession
+import org.hamcrest.CoreMatchers.is
+import org.mockito.ArgumentMatchers.any
+import org.mockito.Mockito._
+import org.scalatest.BeforeAndAfter
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+import play.api.libs.json.Json
+import reactor.core.publisher.Mono
+
+import scala.jdk.CollectionConverters._
+
+object SessionRoutesTest {
+  private val JMAP_SESSION = "/jmap/session"
+  private val TEST_CONFIGURATION = JMAPConfiguration.builder.enable.randomPort.build
+  private val BOB = Username.of("bob@james.org")
+}
+
+class SessionRoutesTest extends AnyFlatSpec with BeforeAndAfter with Matchers {
+
+  var jmapServer: JMAPServer = _
+  var sessionSupplier: SessionSupplier = _
+
+  before {
+    val mockedSession = mock(classOf[MailboxSession])
+    when(mockedSession.getUser)
+      .thenReturn(BOB)
+
+    val mockedAuthFilter = mock(classOf[Authenticator])
+    when(mockedAuthFilter.authenticate(any()))
+      .thenReturn(Mono.just(mockedSession))
+
+    sessionSupplier = spy(new SessionSupplier())
+    val jmapRoutes: Set[JMAPRoutes] = Set(new SessionRoutes(
+      sessionSupplier = sessionSupplier,
+      authFilter = mockedAuthFilter))
+    jmapServer = new JMAPServer(
+      TEST_CONFIGURATION,
+      jmapRoutes.asJava)
+    jmapServer.start()
+
+    RestAssured.requestSpecification = new RequestSpecBuilder()
+      .setContentType(ContentType.JSON)
+      .setAccept(ContentType.JSON)
+      .setConfig(newConfig.encoderConfig(encoderConfig.defaultContentCharset(StandardCharsets.UTF_8)))
+      .setPort(jmapServer.getPort.getValue)
+      .setBasePath(SessionRoutesTest.JMAP_SESSION)
+      .build()
+  }
+
+  after {
+    jmapServer.stop()
+  }
+
+  "get" should "return OK status" in {
+    RestAssured.when()
+      .get
+    .then
+      .statusCode(HttpStatus.SC_OK)
+      .contentType(ContentType.JSON)
+  }
+
+  "get" should "return correct session" in {
+    val sessionJson = RestAssured.`with`()
+        .get
+      .thenReturn
+        .getBody
+        .asString()
+    val expectedJson = """{
+                         |  "capabilities" : {
+                         |    "urn:ietf:params:jmap:core" : {
+                         |      "maxSizeUpload" : 10000000,
+                         |      "maxConcurrentUpload" : 4,
+                         |      "maxSizeRequest" : 10000000,
+                         |      "maxConcurrentRequests" : 4,
+                         |      "maxCallsInRequest" : 16,
+                         |      "maxObjectsInGet" : 500,
+                         |      "maxObjectsInSet" : 500,
+                         |      "collationAlgorithms" : [ "i;unicode-casemap" ]
+                         |    },
+                         |    "urn:ietf:params:jmap:mail" : {
+                         |      "maxMailboxesPerEmail" : 10000000,
+                         |      "maxMailboxDepth" : null,
+                         |      "maxSizeMailboxName" : 200,
+                         |      "maxSizeAttachmentsPerEmail" : 20000000,
+                         |      "emailQuerySortOptions" : [ "receivedAt", "cc", "from", "to", "subject", "size", "sentAt", "hasKeyword", "uid", "Id" ],
+                         |      "mayCreateTopLevelMailbox" : true
+                         |    }
+                         |  },
+                         |  "accounts" : {
+                         |    "25742733157" : {
+                         |      "name" : "bob@james.org",
+                         |      "isPersonal" : true,
+                         |      "isReadOnly" : false,
+                         |      "accountCapabilities" : {
+                         |        "urn:ietf:params:jmap:core" : {
+                         |          "maxSizeUpload" : 10000000,
+                         |          "maxConcurrentUpload" : 4,
+                         |          "maxSizeRequest" : 10000000,
+                         |          "maxConcurrentRequests" : 4,
+                         |          "maxCallsInRequest" : 16,
+                         |          "maxObjectsInGet" : 500,
+                         |          "maxObjectsInSet" : 500,
+                         |          "collationAlgorithms" : [ "i;unicode-casemap" ]
+                         |        },
+                         |        "urn:ietf:params:jmap:mail" : {
+                         |          "maxMailboxesPerEmail" : 10000000,
+                         |          "maxMailboxDepth" : null,
+                         |          "maxSizeMailboxName" : 200,
+                         |          "maxSizeAttachmentsPerEmail" : 20000000,
+                         |          "emailQuerySortOptions" : [ "receivedAt", "cc", "from", "to", "subject", "size", "sentAt", "hasKeyword", "uid", "Id" ],
+                         |          "mayCreateTopLevelMailbox" : true
+                         |        }
+                         |      }
+                         |    }
+                         |  },
+                         |  "primaryAccounts" : {
+                         |    "urn:ietf:params:jmap:core" : "25742733157",
+                         |    "urn:ietf:params:jmap:mail" : "25742733157"
+                         |  },
+                         |  "username" : "bob@james.org",
+                         |  "apiUrl" : "http://this-url-is-hardcoded.org/jmap",
+                         |  "downloadUrl" : "http://this-url-is-hardcoded.org/download",
+                         |  "uploadUrl" : "http://this-url-is-hardcoded.org/upload",
+                         |  "eventSourceUrl" : "http://this-url-is-hardcoded.org/eventSource",
+                         |  "state" : "000001"
+                         |}""".stripMargin
+
+    Json.parse(sessionJson) should equal(Json.parse(expectedJson))
+  }
+
+  "get" should "return 500 when unexpected Id serialization" in {
+    when(sessionSupplier.usernameHashCode(BOB))
+      .thenReturn("INVALID_JMAP_ID_()*&*$(#*")
+
+    RestAssured.when()
+        .get
+      .then
+        .statusCode(HttpStatus.SC_INTERNAL_SERVER_ERROR)
+  }
+
+  "get" should "return empty content type when unexpected Id serialization" in {
+    when(sessionSupplier.usernameHashCode(BOB))
+      .thenReturn("INVALID_JMAP_ID_()*&*$(#*")
+
+    RestAssured.when()
+        .get
+      .then
+        .contentType(is(""))
+  }
+
+  "get" should "return empty body when unexpected Id serialization" in {
+    when(sessionSupplier.usernameHashCode(BOB))
+      .thenReturn("INVALID_JMAP_ID_()*&*$(#*")
+
+    RestAssured.`with`()
+        .get
+      .thenReturn()
+        .getBody
+        .asString() should equal("")
+  }
+}
diff --git a/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/http/SessionSupplierTest.scala b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/http/SessionSupplierTest.scala
new file mode 100644
index 0000000..1b2bb0b
--- /dev/null
+++ b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/http/SessionSupplierTest.scala
@@ -0,0 +1,57 @@
+/** **************************************************************
+ * 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.http
+
+import eu.timepit.refined.auto._
+import org.apache.james.core.Username
+import org.apache.james.jmap.http.SessionSupplierTest.USERNAME
+import org.apache.james.jmap.model.Id.Id
+import org.scalatest.matchers.should.Matchers
+import org.scalatest.wordspec.AnyWordSpec
+
+object SessionSupplierTest {
+  private val USERNAME = Username.of("username@james.org")
+}
+
+class SessionSupplierTest extends AnyWordSpec with Matchers {
+
+  "generate" should {
+    "return correct username" in {
+      new SessionSupplier().generate(USERNAME).block().username should equal(USERNAME)
+    }
+
+    "return correct account" which {
+      val accounts = new SessionSupplier().generate(USERNAME).block().accounts
+
+      "has size" in {
+        accounts should have size 1
+      }
+
+      "has name" in {
+        accounts.view.mapValues(_.name).values.toList should equal(List(USERNAME))
+      }
+
+      "has id" in {
+        val usernameHashCode: Id = "22267206120"
+        accounts.keys.toList should equal(List(usernameHashCode))
+      }
+    }
+  }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: server-dev-unsubscribe@james.apache.org
For additional commands, e-mail: server-dev-help@james.apache.org