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 <[email protected]> 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("[email protected]") +} + +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" : "[email protected]", + | "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" : "[email protected]", + | "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("[email protected]") +} + +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: [email protected] For additional commands, e-mail: [email protected]
