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 <rcord...@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-unsubscr...@james.apache.org For additional commands, e-mail: server-dev-h...@james.apache.org