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