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 rc...@apache.org on 2020/03/18 03:03:44 UTC

[james-project] 11/15: JAMES-3078 JMAPApiRoutes

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 202120510e44c49e55ebe6719ae836a6cd2843bb
Author: Rene Cordier <rc...@linagora.com>
AuthorDate: Wed Mar 11 13:47:57 2020 +0700

    JAMES-3078 JMAPApiRoutes
---
 .../org/apache/james/jmap/http/JMAPApiRoutes.java  | 145 +++++++++++++++++++
 .../apache/james/jmap/http/JMAPApiRoutesTest.java  | 158 +++++++++++++++++++++
 2 files changed, 303 insertions(+)

diff --git a/server/protocols/jmap-draft/src/main/java/org/apache/james/jmap/http/JMAPApiRoutes.java b/server/protocols/jmap-draft/src/main/java/org/apache/james/jmap/http/JMAPApiRoutes.java
new file mode 100644
index 0000000..5451a8f
--- /dev/null
+++ b/server/protocols/jmap-draft/src/main/java/org/apache/james/jmap/http/JMAPApiRoutes.java
@@ -0,0 +1,145 @@
+/****************************************************************
+ * 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 static io.netty.handler.codec.http.HttpHeaderNames.CONTENT_TYPE;
+import static io.netty.handler.codec.http.HttpResponseStatus.OK;
+import static org.apache.james.jmap.HttpConstants.JSON_CONTENT_TYPE;
+import static org.apache.james.jmap.http.JMAPUrls.JMAP;
+
+import java.io.IOException;
+
+import javax.inject.Inject;
+
+import org.apache.james.jmap.JMAPRoutes;
+import org.apache.james.jmap.draft.exceptions.BadRequestException;
+import org.apache.james.jmap.draft.exceptions.InternalErrorException;
+import org.apache.james.jmap.draft.exceptions.UnauthorizedException;
+import org.apache.james.jmap.draft.methods.RequestHandler;
+import org.apache.james.jmap.draft.model.AuthenticatedRequest;
+import org.apache.james.jmap.draft.model.InvocationRequest;
+import org.apache.james.jmap.draft.model.InvocationResponse;
+import org.apache.james.mailbox.MailboxSession;
+import org.apache.james.metrics.api.MetricFactory;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.fasterxml.jackson.core.JsonParser.Feature;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+import reactor.core.scheduler.Schedulers;
+import reactor.netty.http.server.HttpServerRequest;
+import reactor.netty.http.server.HttpServerResponse;
+import reactor.netty.http.server.HttpServerRoutes;
+
+public class JMAPApiRoutes implements JMAPRoutes {
+    public static final Logger LOGGER = LoggerFactory.getLogger(JMAPApiRoutes.class);
+
+    private final ObjectMapper objectMapper;
+    private final RequestHandler requestHandler;
+    private final MetricFactory metricFactory;
+    private final AuthenticationReactiveFilter authenticationReactiveFilter;
+    private final UserProvisioner userProvisioner;
+    private final DefaultMailboxesReactiveProvisioner defaultMailboxesProvisioner;
+
+    @Inject
+    public JMAPApiRoutes(RequestHandler requestHandler, MetricFactory metricFactory, AuthenticationReactiveFilter authenticationReactiveFilter, UserProvisioner userProvisioner, DefaultMailboxesReactiveProvisioner defaultMailboxesProvisioner) {
+        this.requestHandler = requestHandler;
+        this.metricFactory = metricFactory;
+        this.authenticationReactiveFilter = authenticationReactiveFilter;
+        this.userProvisioner = userProvisioner;
+        this.defaultMailboxesProvisioner = defaultMailboxesProvisioner;
+        this.objectMapper = new ObjectMapper();
+        objectMapper.configure(Feature.ALLOW_UNQUOTED_CONTROL_CHARS, true);
+    }
+
+    @Override
+    public Logger logger() {
+        return LOGGER;
+    }
+
+    @Override
+    public HttpServerRoutes define(HttpServerRoutes builder) {
+        return builder.post(JMAP, this::post)
+            .options(JMAP, CORS_CONTROL);
+    }
+
+    private Mono<Void> post(HttpServerRequest request, HttpServerResponse response) {
+        return authenticationReactiveFilter.authenticate(request)
+            .flatMap(session -> Flux.merge(
+                    userProvisioner.provisionUser(session),
+                    defaultMailboxesProvisioner.createMailboxesIfNeeded(session))
+                .then()
+                .thenReturn(session))
+            .flatMap(session -> Mono.from(metricFactory.runPublishingTimerMetric("JMAP-request",
+                post(request, response, session))))
+            .onErrorResume(BadRequestException.class, e -> handleBadRequest(response, e))
+            .onErrorResume(UnauthorizedException.class, e -> handleAuthenticationFailure(response, e))
+            .onErrorResume(e -> handleInternalError(response, e))
+            .subscribeOn(Schedulers.elastic());
+    }
+
+    private Mono<Void> post(HttpServerRequest request, HttpServerResponse response, MailboxSession session) {
+        Flux<Object[]> responses =
+            requestAsJsonStream(request)
+                .map(InvocationRequest::deserialize)
+                .map(invocationRequest -> AuthenticatedRequest.decorate(invocationRequest, session))
+                .concatMap(this::handle)
+                .map(InvocationResponse::asProtocolSpecification);
+
+        return sendResponses(response, responses);
+    }
+
+    private Mono<Void> sendResponses(HttpServerResponse response, Flux<Object[]> responses) {
+        return responses.collectList()
+            .map(objects -> {
+                try {
+                    return objectMapper.writeValueAsString(objects);
+                } catch (JsonProcessingException e) {
+                    throw new InternalErrorException("error serialising JMAP API response json");
+                }
+            })
+            .flatMap(json -> response.status(OK)
+                .header(CONTENT_TYPE, JSON_CONTENT_TYPE)
+                .sendString(Mono.just(json))
+                .then());
+    }
+
+    private Flux<? extends InvocationResponse> handle(AuthenticatedRequest request) {
+        return Mono.fromCallable(() -> requestHandler.handle(request))
+            .flatMapMany(Flux::fromStream)
+            .subscribeOn(Schedulers.elastic());
+    }
+
+    private Flux<JsonNode[]> requestAsJsonStream(HttpServerRequest req) {
+        return req.receive().aggregate().asInputStream()
+            .map(inputStream -> {
+                try {
+                    return objectMapper.readValue(inputStream, JsonNode[][].class);
+                } catch (IOException e) {
+                    throw new BadRequestException("Error deserializing JSON", e);
+                }
+            })
+            .flatMapMany(Flux::fromArray);
+    }
+}
diff --git a/server/protocols/jmap-draft/src/test/java/org/apache/james/jmap/http/JMAPApiRoutesTest.java b/server/protocols/jmap-draft/src/test/java/org/apache/james/jmap/http/JMAPApiRoutesTest.java
new file mode 100644
index 0000000..c0b97d2
--- /dev/null
+++ b/server/protocols/jmap-draft/src/test/java/org/apache/james/jmap/http/JMAPApiRoutesTest.java
@@ -0,0 +1,158 @@
+/****************************************************************
+ * 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 static io.restassured.RestAssured.given;
+import static io.restassured.config.EncoderConfig.encoderConfig;
+import static io.restassured.config.RestAssuredConfig.newConfig;
+import static org.apache.james.jmap.http.JMAPUrls.JMAP;
+import static org.hamcrest.Matchers.equalTo;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.nio.charset.StandardCharsets;
+import java.util.stream.Stream;
+
+import org.apache.james.jmap.draft.methods.ErrorResponse;
+import org.apache.james.jmap.draft.methods.Method;
+import org.apache.james.jmap.draft.methods.RequestHandler;
+import org.apache.james.jmap.draft.model.InvocationResponse;
+import org.apache.james.jmap.draft.model.MethodCallId;
+import org.apache.james.mailbox.MailboxSession;
+import org.apache.james.metrics.tests.RecordingMetricFactory;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.JsonNodeFactory;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+
+import io.restassured.RestAssured;
+import io.restassured.builder.RequestSpecBuilder;
+import io.restassured.http.ContentType;
+import reactor.core.publisher.Mono;
+import reactor.netty.DisposableServer;
+import reactor.netty.http.server.HttpServer;
+
+public class JMAPApiRoutesTest {
+    private static final int RANDOM_PORT = 0;
+
+    private DisposableServer server;
+    private RequestHandler requestHandler;
+    private AuthenticationReactiveFilter mockedAuthFilter;
+    private UserProvisioner mockedUserProvisionner;
+    private DefaultMailboxesReactiveProvisioner mockedMailboxesProvisionner;
+
+    @Before
+    public void setup() throws Exception {
+        requestHandler = mock(RequestHandler.class);
+        mockedAuthFilter = mock(AuthenticationReactiveFilter.class);
+        mockedUserProvisionner = mock(UserProvisioner.class);
+        mockedMailboxesProvisionner = mock(DefaultMailboxesReactiveProvisioner.class);
+
+        JMAPApiRoutes jmapApiRoutes = new JMAPApiRoutes(requestHandler, new RecordingMetricFactory(),
+            mockedAuthFilter, mockedUserProvisionner, mockedMailboxesProvisionner);
+
+        server = HttpServer.create()
+            .port(RANDOM_PORT)
+            .route(jmapApiRoutes::define)
+            .bindNow();
+
+        RestAssured.requestSpecification = new RequestSpecBuilder()
+            .setContentType(ContentType.JSON)
+            .setAccept(ContentType.JSON)
+            .setConfig(newConfig().encoderConfig(encoderConfig().defaultContentCharset(StandardCharsets.UTF_8)))
+            .setPort(server.port())
+            .setBasePath(JMAP)
+            .build();
+
+        when(mockedAuthFilter.authenticate(any()))
+            .thenReturn(Mono.just(mock(MailboxSession.class)));
+        when(mockedUserProvisionner.provisionUser(any()))
+            .thenReturn(Mono.empty());
+        when(mockedMailboxesProvisionner.createMailboxesIfNeeded(any()))
+            .thenReturn(Mono.empty());
+    }
+
+    @After
+    public void teardown() {
+        server.disposeNow();
+    }
+
+    @Test
+    public void mustReturnBadRequestOnMalformedRequest() {
+        String missingAnOpeningBracket = "[\"getAccounts\", {\"state\":false}, \"#0\"]]";
+
+        given()
+            .body(missingAnOpeningBracket)
+        .when()
+            .post()
+        .then()
+            .statusCode(400);
+    }
+
+    @Test
+    public void mustReturnInvalidArgumentOnInvalidState() throws Exception {
+        ObjectNode json = new ObjectNode(new JsonNodeFactory(false));
+        json.put("type", "invalidArgument");
+
+        when(requestHandler.handle(any()))
+            .thenReturn(Stream.of(new InvocationResponse(ErrorResponse.ERROR_METHOD, json, MethodCallId.of("#0"))));
+
+        given()
+            .body("[[\"getAccounts\", {\"state\":false}, \"#0\"]]")
+        .when()
+            .post()
+        .then()
+            .statusCode(200)
+            .body(equalTo("[[\"error\",{\"type\":\"invalidArgument\"},\"#0\"]]"));
+    }
+
+    @Test
+    public void mustReturnAccountsOnValidRequest() throws Exception {
+        ObjectNode json = new ObjectNode(new JsonNodeFactory(false));
+        json.put("state", "f6a7e214");
+        ArrayNode arrayNode = json.putArray("list");
+        ObjectNode list = new ObjectNode(new JsonNodeFactory(false));
+        list.put("id", "6asf5");
+        list.put("name", "roger@barcamp");
+        arrayNode.add(list);
+
+        when(requestHandler.handle(any()))
+            .thenReturn(Stream.of(new InvocationResponse(Method.Response.name("accounts"), json, MethodCallId.of("#0"))));
+
+        given()
+            .body("[[\"getAccounts\", {}, \"#0\"]]")
+        .when()
+            .post()
+        .then()
+            .statusCode(200)
+            .body(equalTo("[[\"accounts\",{" +
+                    "\"state\":\"f6a7e214\"," + 
+                    "\"list\":[" + 
+                        "{" + 
+                        "\"id\":\"6asf5\"," + 
+                        "\"name\":\"roger@barcamp\"" + 
+                        "}" + 
+                    "]" + 
+                    "},\"#0\"]]"));
+    }
+}


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