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 ad...@apache.org on 2018/04/06 13:20:29 UTC

[10/24] james-project git commit: JAMES-2366 Introduce REST API for forward

JAMES-2366 Introduce REST API for forward


Project: http://git-wip-us.apache.org/repos/asf/james-project/repo
Commit: http://git-wip-us.apache.org/repos/asf/james-project/commit/92f4a682
Tree: http://git-wip-us.apache.org/repos/asf/james-project/tree/92f4a682
Diff: http://git-wip-us.apache.org/repos/asf/james-project/diff/92f4a682

Branch: refs/heads/master
Commit: 92f4a6823a22164341135eb08603625f02449dc3
Parents: 53d760b
Author: benwa <bt...@linagora.com>
Authored: Thu Mar 29 16:16:07 2018 +0700
Committer: Antoine Duprat <ad...@linagora.com>
Committed: Fri Apr 6 15:04:48 2018 +0200

----------------------------------------------------------------------
 .../dto/ForwardDestinationResponse.java         |  32 +
 .../james/webadmin/routes/ForwardRoutes.java    | 263 ++++++++
 .../webadmin/routes/ForwardRoutesTest.java      | 676 +++++++++++++++++++
 3 files changed, 971 insertions(+)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/james-project/blob/92f4a682/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/dto/ForwardDestinationResponse.java
----------------------------------------------------------------------
diff --git a/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/dto/ForwardDestinationResponse.java b/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/dto/ForwardDestinationResponse.java
new file mode 100644
index 0000000..5e681c0
--- /dev/null
+++ b/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/dto/ForwardDestinationResponse.java
@@ -0,0 +1,32 @@
+/****************************************************************
+ * 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.webadmin.dto;
+
+public class ForwardDestinationResponse {
+    private final String mailAddress;
+
+    public ForwardDestinationResponse(String mailAddress) {
+        this.mailAddress = mailAddress;
+    }
+
+    public String getMailAddress() {
+        return mailAddress;
+    }
+}

http://git-wip-us.apache.org/repos/asf/james-project/blob/92f4a682/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/routes/ForwardRoutes.java
----------------------------------------------------------------------
diff --git a/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/routes/ForwardRoutes.java b/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/routes/ForwardRoutes.java
new file mode 100644
index 0000000..ffca23c
--- /dev/null
+++ b/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/routes/ForwardRoutes.java
@@ -0,0 +1,263 @@
+/****************************************************************
+ * 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.webadmin.routes;
+
+import static org.apache.james.webadmin.Constants.SEPARATOR;
+import static spark.Spark.halt;
+
+import java.io.UnsupportedEncodingException;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+
+import javax.inject.Inject;
+import javax.mail.internet.AddressException;
+import javax.ws.rs.DELETE;
+import javax.ws.rs.GET;
+import javax.ws.rs.PUT;
+import javax.ws.rs.Path;
+import javax.ws.rs.Produces;
+
+import org.apache.james.core.MailAddress;
+import org.apache.james.domainlist.api.DomainListException;
+import org.apache.james.rrt.api.RecipientRewriteTable;
+import org.apache.james.rrt.api.RecipientRewriteTableException;
+import org.apache.james.rrt.lib.Mapping;
+import org.apache.james.rrt.lib.Mappings;
+import org.apache.james.user.api.UsersRepository;
+import org.apache.james.user.api.UsersRepositoryException;
+import org.apache.james.webadmin.Constants;
+import org.apache.james.webadmin.Routes;
+import org.apache.james.webadmin.dto.ForwardDestinationResponse;
+import org.apache.james.webadmin.utils.ErrorResponder;
+import org.apache.james.webadmin.utils.ErrorResponder.ErrorType;
+import org.apache.james.webadmin.utils.JsonExtractException;
+import org.apache.james.webadmin.utils.JsonTransformer;
+import org.eclipse.jetty.http.HttpStatus;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.github.steveash.guavate.Guavate;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSortedSet;
+
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiImplicitParam;
+import io.swagger.annotations.ApiImplicitParams;
+import io.swagger.annotations.ApiOperation;
+import io.swagger.annotations.ApiResponse;
+import io.swagger.annotations.ApiResponses;
+import spark.HaltException;
+import spark.Request;
+import spark.Response;
+import spark.Service;
+
+@Api(tags = "Address Forwards")
+@Path(ForwardRoutes.ROOT_PATH)
+@Produces(Constants.JSON_CONTENT_TYPE)
+public class ForwardRoutes implements Routes {
+
+    public static final String ROOT_PATH = "address/forwards";
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(ForwardRoutes.class);
+
+    private static final String FORWARD_BASE_ADDRESS = "forwardBaseAddress";
+    private static final String FORWARD_ADDRESS_PATH = ROOT_PATH + SEPARATOR + ":" + FORWARD_BASE_ADDRESS;
+    private static final String FORWARD_DESTINATION_ADDRESS = "forwardDestinationAddress";
+    private static final String USER_IN_FORWARD_DESTINATION_ADDRESSES_PATH = FORWARD_ADDRESS_PATH + SEPARATOR +
+        "targets" + SEPARATOR + ":" + FORWARD_DESTINATION_ADDRESS;
+    private static final String MAILADDRESS_ASCII_DISCLAIMER = "Note that email addresses are restricted to ASCII character set. " +
+        "Mail addresses not matching this criteria will be rejected.";
+
+    private final UsersRepository usersRepository;
+    private final JsonTransformer jsonTransformer;
+    private final RecipientRewriteTable recipientRewriteTable;
+
+    @Inject
+    @VisibleForTesting
+    ForwardRoutes(RecipientRewriteTable recipientRewriteTable, UsersRepository usersRepository, JsonTransformer jsonTransformer) {
+        this.usersRepository = usersRepository;
+        this.jsonTransformer = jsonTransformer;
+        this.recipientRewriteTable = recipientRewriteTable;
+    }
+
+    @Override
+    public void define(Service service) {
+        service.get(ROOT_PATH, this::listForwards, jsonTransformer);
+        service.get(FORWARD_ADDRESS_PATH, this::listForwardDestinations, jsonTransformer);
+        service.put(FORWARD_ADDRESS_PATH, this::throwUnknownPath);
+        service.put(USER_IN_FORWARD_DESTINATION_ADDRESSES_PATH, this::addToForwardDestinations);
+        service.delete(FORWARD_ADDRESS_PATH, this::throwUnknownPath);
+        service.delete(USER_IN_FORWARD_DESTINATION_ADDRESSES_PATH, this::removeFromForwardDestination);
+    }
+
+    public Object throwUnknownPath(Request request, Response response) {
+        throw ErrorResponder.builder()
+            .statusCode(HttpStatus.BAD_REQUEST_400)
+            .type(ErrorType.INVALID_ARGUMENT)
+            .message("An destination address needs to be specified in the path")
+            .haltError();
+    }
+
+    @GET
+    @Path(ROOT_PATH)
+    @ApiOperation(value = "getting forwards list")
+    @ApiResponses(value = {
+        @ApiResponse(code = HttpStatus.OK_200, message = "OK", response = List.class),
+        @ApiResponse(code = HttpStatus.INTERNAL_SERVER_ERROR_500,
+            message = "Internal server error - Something went bad on the server side.")
+    })
+    public Set<String> listForwards(Request request, Response response) throws RecipientRewriteTableException {
+        return Optional.ofNullable(recipientRewriteTable.getAllMappings())
+            .map(mappings ->
+                mappings.entrySet().stream()
+                    .filter(e -> e.getValue().contains(Mapping.Type.Forward))
+                    .map(Map.Entry::getKey)
+                    .collect(Guavate.toImmutableSortedSet()))
+            .orElse(ImmutableSortedSet.of());
+    }
+
+    @PUT
+    @Path(ROOT_PATH + "/{" + FORWARD_BASE_ADDRESS + "}/targets/{" + FORWARD_DESTINATION_ADDRESS + "}")
+    @ApiOperation(value = "adding a destination address into a forward")
+    @ApiImplicitParams({
+        @ApiImplicitParam(required = true, dataType = "string", name = FORWARD_BASE_ADDRESS, paramType = "path",
+            value = "Base mail address of the forward. Sending a mail to that address will send it to all forward destinations.\n" +
+            MAILADDRESS_ASCII_DISCLAIMER),
+        @ApiImplicitParam(required = true, dataType = "string", name = FORWARD_DESTINATION_ADDRESS, paramType = "path",
+            value = "A destination mail address of the forward. Sending a mail to the base address will send an email to " +
+                "that email address (as well as other destinations).\n" +
+                MAILADDRESS_ASCII_DISCLAIMER)
+    })
+    @ApiResponses(value = {
+        @ApiResponse(code = HttpStatus.OK_200, message = "OK", response = List.class),
+        @ApiResponse(code = HttpStatus.BAD_REQUEST_400, message = FORWARD_BASE_ADDRESS + " or forward structure format is not valid"),
+        @ApiResponse(code = HttpStatus.NOT_FOUND_404, message = "requested base forward address does not match a user"),
+        @ApiResponse(code = HttpStatus.INTERNAL_SERVER_ERROR_500,
+            message = "Internal server error - Something went bad on the server side.")
+    })
+    public HaltException addToForwardDestinations(Request request, Response response) throws JsonExtractException, AddressException, RecipientRewriteTableException, UsersRepositoryException, DomainListException {
+        MailAddress forwardBaseAddress = parseMailAddress(request.params(FORWARD_BASE_ADDRESS));
+        ensureUserExist(forwardBaseAddress);
+        MailAddress destinationAddress = parseMailAddress(request.params(FORWARD_DESTINATION_ADDRESS));
+        recipientRewriteTable.addForwardMapping(forwardBaseAddress.getLocalPart(), forwardBaseAddress.getDomain(), destinationAddress.asString());
+        return halt(HttpStatus.CREATED_201);
+    }
+
+    private void ensureUserExist(MailAddress mailAddress) throws UsersRepositoryException {
+        if (!usersRepository.contains(mailAddress.asString())) {
+            throw ErrorResponder.builder()
+                .statusCode(HttpStatus.NOT_FOUND_404)
+                .type(ErrorType.INVALID_ARGUMENT)
+                .message("Requested base forward address does not correspond to a user")
+                .haltError();
+        }
+    }
+
+
+    @DELETE
+    @Path(ROOT_PATH + "/{" + FORWARD_BASE_ADDRESS + "}/targets/{" + FORWARD_DESTINATION_ADDRESS + "}")
+    @ApiOperation(value = "remove a destination address from a forward")
+    @ApiImplicitParams({
+        @ApiImplicitParam(required = true, dataType = "string", name = FORWARD_BASE_ADDRESS, paramType = "path"),
+        @ApiImplicitParam(required = true, dataType = "string", name = FORWARD_DESTINATION_ADDRESS, paramType = "path")
+    })
+    @ApiResponses(value = {
+        @ApiResponse(code = HttpStatus.OK_200, message = "OK", response = List.class),
+        @ApiResponse(code = HttpStatus.BAD_REQUEST_400,
+            message = FORWARD_BASE_ADDRESS + " or forward structure format is not valid"),
+        @ApiResponse(code = HttpStatus.INTERNAL_SERVER_ERROR_500,
+            message = "Internal server error - Something went bad on the server side.")
+    })
+    public HaltException removeFromForwardDestination(Request request, Response response) throws JsonExtractException, AddressException, RecipientRewriteTableException {
+        MailAddress baseAddress = parseMailAddress(request.params(FORWARD_BASE_ADDRESS));
+        MailAddress destinationAddressToBeRemoved = parseMailAddress(request.params(FORWARD_DESTINATION_ADDRESS));
+        recipientRewriteTable.removeForwardMapping(
+            baseAddress.getLocalPart(),
+            baseAddress.getDomain(),
+            destinationAddressToBeRemoved.asString());
+        return halt(HttpStatus.OK_200);
+    }
+
+    @GET
+    @Path(ROOT_PATH + "/{" + FORWARD_BASE_ADDRESS + "}")
+    @ApiOperation(value = "listing forward destinations")
+    @ApiImplicitParams({
+        @ApiImplicitParam(required = true, dataType = "string", name = FORWARD_BASE_ADDRESS, paramType = "path")
+    })
+    @ApiResponses(value = {
+        @ApiResponse(code = HttpStatus.OK_200, message = "OK", response = List.class),
+        @ApiResponse(code = HttpStatus.BAD_REQUEST_400, message = "The forward is not an address"),
+        @ApiResponse(code = HttpStatus.NOT_FOUND_404, message = "The forward does not exist"),
+        @ApiResponse(code = HttpStatus.INTERNAL_SERVER_ERROR_500,
+            message = "Internal server error - Something went bad on the server side.")
+    })
+    public ImmutableSet<ForwardDestinationResponse> listForwardDestinations(Request request, Response response) throws RecipientRewriteTable.ErrorMappingException, RecipientRewriteTableException {
+        MailAddress baseAddress = parseMailAddress(request.params(FORWARD_BASE_ADDRESS));
+        Mappings mappings = recipientRewriteTable.getMappings(baseAddress.getLocalPart(), baseAddress.getDomain());
+
+        ensureNonEmptyMappings(mappings);
+
+        return mappings.select(Mapping.Type.Forward)
+                .asStream()
+                .map(mapping -> mapping.asMailAddress()
+                        .orElseThrow(() -> new IllegalStateException(String.format("Can not compute address for mapping %s", mapping.asString()))))
+                .map(MailAddress::asString)
+                .sorted()
+                .map(ForwardDestinationResponse::new)
+                .collect(Guavate.toImmutableSet());
+    }
+
+    private MailAddress parseMailAddress(String address) {
+        try {
+            String decodedAddress = URLDecoder.decode(address, StandardCharsets.UTF_8.displayName());
+            return new MailAddress(decodedAddress);
+        } catch (AddressException e) {
+            throw ErrorResponder.builder()
+                .statusCode(HttpStatus.BAD_REQUEST_400)
+                .type(ErrorType.INVALID_ARGUMENT)
+                .message("The forward is not an email address")
+                .cause(e)
+                .haltError();
+        } catch (UnsupportedEncodingException e) {
+            LOGGER.error("UTF-8 should be a valid encoding");
+            throw ErrorResponder.builder()
+                .statusCode(HttpStatus.INTERNAL_SERVER_ERROR_500)
+                .type(ErrorType.SERVER_ERROR)
+                .message("Internal server error - Something went bad on the server side.")
+                .cause(e)
+                .haltError();
+        }
+    }
+
+    private void ensureNonEmptyMappings(Mappings mappings) {
+        if (mappings == null || mappings.isEmpty()) {
+            throw ErrorResponder.builder()
+                .statusCode(HttpStatus.NOT_FOUND_404)
+                .type(ErrorType.INVALID_ARGUMENT)
+                .message("The forward does not exist")
+                .haltError();
+        }
+    }
+}

http://git-wip-us.apache.org/repos/asf/james-project/blob/92f4a682/server/protocols/webadmin/webadmin-data/src/test/java/org/apache/james/webadmin/routes/ForwardRoutesTest.java
----------------------------------------------------------------------
diff --git a/server/protocols/webadmin/webadmin-data/src/test/java/org/apache/james/webadmin/routes/ForwardRoutesTest.java b/server/protocols/webadmin/webadmin-data/src/test/java/org/apache/james/webadmin/routes/ForwardRoutesTest.java
new file mode 100644
index 0000000..11a1c10
--- /dev/null
+++ b/server/protocols/webadmin/webadmin-data/src/test/java/org/apache/james/webadmin/routes/ForwardRoutesTest.java
@@ -0,0 +1,676 @@
+/****************************************************************
+ * 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.webadmin.routes;
+
+import static com.jayway.restassured.RestAssured.when;
+import static com.jayway.restassured.RestAssured.with;
+import static org.apache.james.webadmin.Constants.SEPARATOR;
+import static org.apache.james.webadmin.WebAdminServer.NO_CONFIGURATION;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.hamcrest.CoreMatchers.containsString;
+import static org.hamcrest.CoreMatchers.hasItems;
+import static org.hamcrest.CoreMatchers.is;
+import static org.mockito.Matchers.any;
+import static org.mockito.Matchers.anyString;
+import static org.mockito.Matchers.eq;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+
+import java.util.List;
+import java.util.Map;
+
+import org.apache.commons.configuration.DefaultConfigurationBuilder;
+import org.apache.james.core.Domain;
+import org.apache.james.dnsservice.api.DNSService;
+import org.apache.james.domainlist.api.DomainList;
+import org.apache.james.domainlist.memory.MemoryDomainList;
+import org.apache.james.metrics.logger.DefaultMetricFactory;
+import org.apache.james.rrt.api.RecipientRewriteTable;
+import org.apache.james.rrt.api.RecipientRewriteTableException;
+import org.apache.james.rrt.memory.MemoryRecipientRewriteTable;
+import org.apache.james.user.api.UsersRepository;
+import org.apache.james.user.memory.MemoryUsersRepository;
+import org.apache.james.webadmin.WebAdminServer;
+import org.apache.james.webadmin.WebAdminUtils;
+import org.apache.james.webadmin.utils.JsonTransformer;
+import org.eclipse.jetty.http.HttpStatus;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import com.jayway.restassured.RestAssured;
+import com.jayway.restassured.filter.log.LogDetail;
+import com.jayway.restassured.http.ContentType;
+
+class ForwardRoutesTest {
+
+    private static final Domain DOMAIN = Domain.of("b.com");
+    public static final String CEDRIC = "cedric@" + DOMAIN.name();
+    public static final String ALICE = "alice@" + DOMAIN.name();
+    public static final String ALICE_WITH_SLASH = "alice/@" + DOMAIN.name();
+    public static final String ALICE_WITH_ENCODED_SLASH = "alice%2F@" + DOMAIN.name();
+    public static final String BOB = "bob@" + DOMAIN.name();
+    public static final String BOB_PASSWORD = "123456";
+    public static final String ALICE_PASSWORD = "789123";
+    public static final String ALICE_SLASH_PASSWORD = "abcdef";
+    public static final String CEDRIC_PASSWORD = "456789";
+
+    private WebAdminServer webAdminServer;
+
+    private void createServer(ForwardRoutes forwardRoutes) throws Exception {
+        webAdminServer = WebAdminUtils.createWebAdminServer(
+            new DefaultMetricFactory(),
+            forwardRoutes);
+        webAdminServer.configure(NO_CONFIGURATION);
+        webAdminServer.await();
+
+        RestAssured.requestSpecification = WebAdminUtils.buildRequestSpecification(webAdminServer)
+            .setBasePath("address/forwards")
+            .log(LogDetail.METHOD)
+            .build();
+    }
+
+    @AfterEach
+    void stop() {
+        webAdminServer.destroy();
+    }
+
+    @Nested
+    class NormalBehaviour {
+
+        MemoryUsersRepository usersRepository;
+        MemoryDomainList domainList;
+        MemoryRecipientRewriteTable memoryRecipientRewriteTable;
+
+        @BeforeEach
+        void setUp() throws Exception {
+            memoryRecipientRewriteTable = new MemoryRecipientRewriteTable();
+            DNSService dnsService = mock(DNSService.class);
+            domainList = new MemoryDomainList(dnsService);
+            domainList.setAutoDetectIP(false);
+            domainList.setAutoDetect(false);
+            domainList.configure(new DefaultConfigurationBuilder());
+            domainList.addDomain(DOMAIN);
+
+            usersRepository = MemoryUsersRepository.withVirtualHosting();
+            usersRepository.setDomainList(domainList);
+            usersRepository.configure(new DefaultConfigurationBuilder());
+
+            usersRepository.addUser(BOB, BOB_PASSWORD);
+            usersRepository.addUser(ALICE, ALICE_PASSWORD);
+            usersRepository.addUser(ALICE_WITH_SLASH, ALICE_SLASH_PASSWORD);
+            usersRepository.addUser(CEDRIC, CEDRIC_PASSWORD);
+
+            createServer(new ForwardRoutes(memoryRecipientRewriteTable, usersRepository, new JsonTransformer()));
+        }
+
+        @Test
+        void getForwardShouldBeEmpty() {
+            when()
+                .get()
+            .then()
+                .contentType(ContentType.JSON)
+                .statusCode(HttpStatus.OK_200)
+                .body(is("[]"));
+        }
+
+        @Test
+        void getForwardShouldListExistingForwardsInAlphabeticOrder() {
+            with()
+                .put(ALICE + SEPARATOR + "targets" + SEPARATOR + BOB);
+
+            with()
+                .put(CEDRIC + SEPARATOR + "targets" + SEPARATOR + BOB);
+
+            List<String> addresses =
+                when()
+                    .get()
+                .then()
+                    .contentType(ContentType.JSON)
+                    .statusCode(HttpStatus.OK_200)
+                    .extract()
+                    .body()
+                    .jsonPath()
+                    .getList(".");
+            assertThat(addresses).containsExactly(ALICE, CEDRIC);
+        }
+
+        @Test
+        void getNotRegisteredForwardShouldReturnNotFound() {
+            Map<String, Object> errors = when()
+                .get("unknown@domain.travel")
+            .then()
+                .statusCode(HttpStatus.NOT_FOUND_404)
+                .contentType(ContentType.JSON)
+                .extract()
+                .body()
+                .jsonPath()
+                .getMap(".");
+
+            assertThat(errors)
+                .containsEntry("statusCode", HttpStatus.NOT_FOUND_404)
+                .containsEntry("type", "InvalidArgument")
+                .containsEntry("message", "The forward does not exist");
+        }
+
+        @Test
+        void putUserInForwardShouldReturnCreated() {
+            when()
+                .put(ALICE + SEPARATOR + "targets" + SEPARATOR + BOB)
+            .then()
+                .statusCode(HttpStatus.CREATED_201);
+        }
+
+        @Test
+        void putUserWithSlashInForwardShouldReturnCreated() {
+            when()
+                .put(BOB + SEPARATOR + "targets" + SEPARATOR + ALICE_WITH_ENCODED_SLASH)
+            .then()
+                .statusCode(HttpStatus.CREATED_201);
+        }
+
+        @Test
+        void putUserWithSlashInForwardShouldAddItAsADestination() {
+            with()
+                .put(BOB + SEPARATOR + "targets" + SEPARATOR + ALICE_WITH_ENCODED_SLASH);
+
+            when()
+                .get(BOB)
+            .then()
+                .contentType(ContentType.JSON)
+                .statusCode(HttpStatus.OK_200)
+                .body("mailAddress", hasItems(ALICE_WITH_SLASH));
+        }
+
+        @Test
+        void putUserInForwardShouldCreateForward() {
+            with()
+                .put(ALICE + SEPARATOR + "targets" + SEPARATOR + BOB);
+
+            when()
+                .get(ALICE)
+            .then()
+                .contentType(ContentType.JSON)
+                .statusCode(HttpStatus.OK_200)
+                .body("mailAddress", hasItems(BOB));
+        }
+
+        @Test
+        void putUserInForwardWithEncodedSlashShouldReturnCreated() {
+            when()
+                .put(ALICE_WITH_ENCODED_SLASH + SEPARATOR + "targets" + SEPARATOR + BOB)
+            .then()
+                .statusCode(HttpStatus.CREATED_201);
+        }
+
+        @Test
+        void putUserInForwardWithEncodedSlashShouldCreateForward() {
+            with()
+                .put(ALICE_WITH_ENCODED_SLASH + SEPARATOR + "targets" + SEPARATOR + BOB);
+
+            when()
+                .get(ALICE_WITH_ENCODED_SLASH)
+            .then()
+                .contentType(ContentType.JSON)
+                .statusCode(HttpStatus.OK_200)
+                .body("mailAddress", hasItems(BOB));
+        }
+
+        @Test
+        void putSameUserInForwardTwiceShouldBeIdempotent() {
+            with()
+                .put(ALICE + SEPARATOR + "targets" + SEPARATOR + BOB);
+
+            with()
+                .put(ALICE + SEPARATOR + "targets" + SEPARATOR + BOB);
+
+            when()
+                .get(ALICE)
+            .then()
+                .contentType(ContentType.JSON)
+                .statusCode(HttpStatus.OK_200)
+                .body("mailAddress", hasItems(BOB));
+        }
+
+        @Test
+        void putUserInForwardShouldAllowSeveralDestinations() {
+            with()
+                .put(ALICE + SEPARATOR + "targets" + SEPARATOR + BOB);
+
+            with()
+                .put(ALICE + SEPARATOR + "targets" + SEPARATOR + CEDRIC);
+
+            when()
+                .get(ALICE)
+            .then()
+                .contentType(ContentType.JSON)
+                .statusCode(HttpStatus.OK_200)
+                .body("mailAddress", hasItems(BOB, CEDRIC));
+        }
+
+        @Test
+        void forwardShouldAllowIdentity() {
+            with()
+                .put(ALICE + SEPARATOR + "targets" + SEPARATOR + ALICE);
+
+            with()
+                .put(ALICE + SEPARATOR + "targets" + SEPARATOR + CEDRIC);
+
+            when()
+                .get(ALICE)
+            .then()
+                .contentType(ContentType.JSON)
+                .statusCode(HttpStatus.OK_200)
+                .body("mailAddress", hasItems(ALICE, CEDRIC));
+        }
+
+        @Test
+        void putUserInForwardShouldRequireExistingBaseUser() {
+            Map<String, Object> errors = when()
+                .put("notFound@" + DOMAIN.name() + SEPARATOR + "targets" + SEPARATOR + BOB)
+            .then()
+                .statusCode(HttpStatus.NOT_FOUND_404)
+                .contentType(ContentType.JSON)
+                .extract()
+                .body()
+                .jsonPath()
+                .getMap(".");
+
+            assertThat(errors)
+                .containsEntry("statusCode", HttpStatus.NOT_FOUND_404)
+                .containsEntry("type", "InvalidArgument")
+                .containsEntry("message", "Requested base forward address does not correspond to a user");
+        }
+
+        @Test
+        void getForwardShouldReturnMembersInAlphabeticOrder() {
+            with()
+                .put(ALICE + SEPARATOR + "targets" + SEPARATOR + BOB);
+
+            with()
+                .put(ALICE + SEPARATOR + "targets" + SEPARATOR + CEDRIC);
+
+            when()
+                .get(ALICE)
+            .then()
+                .contentType(ContentType.JSON)
+                .statusCode(HttpStatus.OK_200)
+                .body("mailAddress", hasItems(BOB, CEDRIC));
+        }
+
+        @Test
+        void forwardShouldAcceptExternalAddresses() {
+            String externalAddress = "external@other.com";
+
+            with()
+                .put(ALICE + SEPARATOR + "targets" + SEPARATOR + externalAddress);
+
+            when()
+                .get(ALICE)
+            .then()
+                .contentType(ContentType.JSON)
+                .statusCode(HttpStatus.OK_200)
+                .body("mailAddress", hasItems(externalAddress));
+        }
+
+        @Test
+        void deleteUserNotInForwardShouldReturnOK() {
+            when()
+                .delete(ALICE + SEPARATOR + "targets" + SEPARATOR + BOB)
+            .then()
+                .statusCode(HttpStatus.OK_200);
+        }
+
+        @Test
+        void deleteLastUserInForwardShouldDeleteForward() {
+            with()
+                .put(ALICE + SEPARATOR + "targets" + SEPARATOR + BOB);
+
+            with()
+                .delete(ALICE + SEPARATOR + "targets" + SEPARATOR + BOB);
+
+            when()
+                .get()
+            .then()
+                .contentType(ContentType.JSON)
+                .statusCode(HttpStatus.OK_200)
+                .body(is("[]"));
+        }
+    }
+
+    @Nested
+    class FilteringOtherRewriteRuleTypes extends NormalBehaviour {
+
+        @BeforeEach
+        void setup() throws Exception {
+            super.setUp();
+            memoryRecipientRewriteTable.addErrorMapping("error", DOMAIN, "disabled");
+            memoryRecipientRewriteTable.addRegexMapping("regex", DOMAIN, ".*@b\\.com");
+            memoryRecipientRewriteTable.addAliasDomainMapping(Domain.of("alias"), DOMAIN);
+        }
+
+    }
+
+    @Nested
+    class ExceptionHandling {
+
+        private RecipientRewriteTable memoryRecipientRewriteTable;
+
+        @BeforeEach
+        void setUp() throws Exception {
+            memoryRecipientRewriteTable = mock(RecipientRewriteTable.class);
+            UsersRepository userRepository = mock(UsersRepository.class);
+            Mockito.when(userRepository.contains(eq(ALICE))).thenReturn(true);
+            DomainList domainList = mock(DomainList.class);
+            Mockito.when(domainList.containsDomain(any())).thenReturn(true);
+            createServer(new ForwardRoutes(memoryRecipientRewriteTable, userRepository, new JsonTransformer()));
+        }
+
+        @Test
+        void getMalformedForwardShouldReturnBadRequest() {
+            Map<String, Object> errors = when()
+                .get("not-an-address")
+            .then()
+                .statusCode(HttpStatus.BAD_REQUEST_400)
+                .contentType(ContentType.JSON)
+                .extract()
+                .body()
+                .jsonPath()
+                .getMap(".");
+
+            assertThat(errors)
+                .containsEntry("statusCode", HttpStatus.BAD_REQUEST_400)
+                .containsEntry("type", "InvalidArgument")
+                .containsEntry("message", "The forward is not an email address")
+                .containsEntry("cause", "Out of data at position 1 in 'not-an-address'");
+        }
+
+        @Test
+        void putMalformedForwardShouldReturnBadRequest() {
+            Map<String, Object> errors = when()
+                .put("not-an-address" + SEPARATOR + "targets" + SEPARATOR + BOB)
+            .then()
+                .statusCode(HttpStatus.BAD_REQUEST_400)
+                .contentType(ContentType.JSON)
+                .extract()
+                .body()
+                .jsonPath()
+                .getMap(".");
+
+            assertThat(errors)
+                .containsEntry("statusCode", HttpStatus.BAD_REQUEST_400)
+                .containsEntry("type", "InvalidArgument")
+                .containsEntry("message", "The forward is not an email address")
+                .containsEntry("cause", "Out of data at position 1 in 'not-an-address'");
+        }
+
+        @Test
+        void putUserInForwardWithSlashShouldReturnNotFound() {
+            when()
+                .put(ALICE_WITH_SLASH + SEPARATOR + "targets" + SEPARATOR + BOB)
+            .then()
+                .statusCode(HttpStatus.NOT_FOUND_404)
+                .body(containsString("404 Not found"));
+        }
+
+        @Test
+        void putUserWithSlashInForwardShouldReturnNotFound() {
+            when()
+                .put(ALICE + SEPARATOR + "targets" + SEPARATOR + ALICE_WITH_SLASH)
+            .then()
+                .statusCode(HttpStatus.NOT_FOUND_404)
+                .body(containsString("404 Not found"));
+        }
+
+        @Test
+        void putMalformedAddressShouldReturnBadRequest() {
+            Map<String, Object> errors = when()
+                .put(ALICE + SEPARATOR + "targets" + SEPARATOR + "not-an-address")
+            .then()
+                .statusCode(HttpStatus.BAD_REQUEST_400)
+                .contentType(ContentType.JSON)
+                .extract()
+                .body()
+                .jsonPath()
+                .getMap(".");
+
+            assertThat(errors)
+                .containsEntry("statusCode", HttpStatus.BAD_REQUEST_400)
+                .containsEntry("type", "InvalidArgument")
+                .containsEntry("message", "The forward is not an email address")
+                .containsEntry("cause", "Out of data at position 1 in 'not-an-address'");
+        }
+
+        @Test
+        void putRequiresTwoPathParams() {
+            when()
+                .put(ALICE)
+            .then()
+                .statusCode(HttpStatus.BAD_REQUEST_400)
+                .body(is(""));
+        }
+
+        @Test
+        void deleteMalformedForwardShouldReturnBadRequest() {
+            Map<String, Object> errors = when()
+                .delete("not-an-address" + SEPARATOR + "targets" + SEPARATOR + ALICE)
+            .then()
+                .statusCode(HttpStatus.BAD_REQUEST_400)
+                .contentType(ContentType.JSON)
+                .extract()
+                .body()
+                .jsonPath()
+                .getMap(".");
+
+            assertThat(errors)
+                .containsEntry("statusCode", HttpStatus.BAD_REQUEST_400)
+                .containsEntry("type", "InvalidArgument")
+                .containsEntry("message", "The forward is not an email address")
+                .containsEntry("cause", "Out of data at position 1 in 'not-an-address'");
+        }
+
+        @Test
+        void deleteMalformedAddressShouldReturnBadRequest() {
+            Map<String, Object> errors = when()
+                .delete(ALICE + SEPARATOR + "targets" + SEPARATOR + "not-an-address")
+            .then()
+                .statusCode(HttpStatus.BAD_REQUEST_400)
+                .contentType(ContentType.JSON)
+                .extract()
+                .body()
+                .jsonPath()
+                .getMap(".");
+
+            assertThat(errors)
+                .containsEntry("statusCode", HttpStatus.BAD_REQUEST_400)
+                .containsEntry("type", "InvalidArgument")
+                .containsEntry("message", "The forward is not an email address")
+                .containsEntry("cause", "Out of data at position 1 in 'not-an-address'");
+        }
+
+        @Test
+        void deleteRequiresTwoPathParams() {
+            when()
+                .delete(ALICE)
+            .then()
+                .statusCode(HttpStatus.BAD_REQUEST_400)
+                .body(is(""));
+        }
+
+        @Test
+        void putShouldReturnErrorWhenRecipientRewriteTableExceptionIsThrown() throws Exception {
+            doThrow(RecipientRewriteTableException.class)
+                .when(memoryRecipientRewriteTable)
+                .addForwardMapping(anyString(), any(), anyString());
+
+            when()
+                .put(ALICE + SEPARATOR + "targets" + SEPARATOR + BOB)
+            .then()
+                .statusCode(HttpStatus.INTERNAL_SERVER_ERROR_500)
+                .body(containsString("500 Internal Server Error"));
+        }
+
+        @Test
+        void putShouldReturnErrorWhenErrorMappingExceptionIsThrown() throws Exception {
+            doThrow(RecipientRewriteTable.ErrorMappingException.class)
+                .when(memoryRecipientRewriteTable)
+                .addForwardMapping(anyString(), any(), anyString());
+
+            when()
+                .put(ALICE + SEPARATOR + "targets" + SEPARATOR + BOB)
+            .then()
+                .statusCode(HttpStatus.INTERNAL_SERVER_ERROR_500)
+                .body(containsString("500 Internal Server Error"));
+        }
+
+        @Test
+        void putShouldReturnErrorWhenRuntimeExceptionIsThrown() throws Exception {
+            doThrow(RuntimeException.class)
+                .when(memoryRecipientRewriteTable)
+                .addForwardMapping(anyString(), any(), anyString());
+
+            when()
+                .put(ALICE + SEPARATOR + "targets" + SEPARATOR + BOB)
+            .then()
+                .statusCode(HttpStatus.INTERNAL_SERVER_ERROR_500)
+                .body(containsString("500 Internal Server Error"));
+        }
+
+        @Test
+        void getAllShouldReturnErrorWhenRecipientRewriteTableExceptionIsThrown() throws Exception {
+            doThrow(RecipientRewriteTableException.class)
+                .when(memoryRecipientRewriteTable)
+                .getAllMappings();
+
+            when()
+                .get()
+            .then()
+                .statusCode(HttpStatus.INTERNAL_SERVER_ERROR_500)
+                .body(containsString("500 Internal Server Error"));
+        }
+
+        @Test
+        void getAllShouldReturnErrorWhenErrorMappingExceptionIsThrown() throws Exception {
+            doThrow(RecipientRewriteTable.ErrorMappingException.class)
+                .when(memoryRecipientRewriteTable)
+                .getAllMappings();
+
+            when()
+                .get()
+            .then()
+                .statusCode(HttpStatus.INTERNAL_SERVER_ERROR_500)
+                .body(containsString("500 Internal Server Error"));
+        }
+
+        @Test
+        void getAllShouldReturnErrorWhenRuntimeExceptionIsThrown() throws Exception {
+            doThrow(RuntimeException.class)
+                .when(memoryRecipientRewriteTable)
+                .getAllMappings();
+
+            when()
+                .get()
+            .then()
+                .statusCode(HttpStatus.INTERNAL_SERVER_ERROR_500)
+                .body(containsString("500 Internal Server Error"));
+        }
+
+        @Test
+        void deleteShouldReturnErrorWhenRecipientRewriteTableExceptionIsThrown() throws Exception {
+            doThrow(RecipientRewriteTableException.class)
+                .when(memoryRecipientRewriteTable)
+                .removeForwardMapping(anyString(), any(), anyString());
+
+            when()
+                .delete(ALICE + SEPARATOR + "targets" + SEPARATOR + BOB)
+            .then()
+                .statusCode(HttpStatus.INTERNAL_SERVER_ERROR_500)
+                .body(containsString("500 Internal Server Error"));
+        }
+
+        @Test
+        void deleteShouldReturnErrorWhenErrorMappingExceptionIsThrown() throws Exception {
+            doThrow(RecipientRewriteTable.ErrorMappingException.class)
+                .when(memoryRecipientRewriteTable)
+                .removeForwardMapping(anyString(), any(), anyString());
+
+            when()
+                .delete(ALICE + SEPARATOR + "targets" + SEPARATOR + BOB)
+            .then()
+                .statusCode(HttpStatus.INTERNAL_SERVER_ERROR_500)
+                .body(containsString("500 Internal Server Error"));
+        }
+
+        @Test
+        void deleteShouldReturnErrorWhenRuntimeExceptionIsThrown() throws Exception {
+            doThrow(RuntimeException.class)
+                .when(memoryRecipientRewriteTable)
+                .removeForwardMapping(anyString(), any(), anyString());
+
+            when()
+                .delete(ALICE + SEPARATOR + "targets" + SEPARATOR + BOB)
+            .then()
+                .statusCode(HttpStatus.INTERNAL_SERVER_ERROR_500)
+                .body(containsString("500 Internal Server Error"));
+        }
+
+        @Test
+        void getShouldReturnErrorWhenRecipientRewriteTableExceptionIsThrown() throws Exception {
+            doThrow(RecipientRewriteTableException.class)
+                .when(memoryRecipientRewriteTable)
+                .getMappings(anyString(), any());
+
+            when()
+                .get(ALICE)
+            .then()
+                .statusCode(HttpStatus.INTERNAL_SERVER_ERROR_500)
+                .body(containsString("500 Internal Server Error"));
+        }
+
+        @Test
+        void getShouldReturnErrorWhenErrorMappingExceptionIsThrown() throws Exception {
+            doThrow(RecipientRewriteTable.ErrorMappingException.class)
+                .when(memoryRecipientRewriteTable)
+                .getMappings(anyString(), any());
+
+            when()
+                .get(ALICE)
+            .then()
+                .statusCode(HttpStatus.INTERNAL_SERVER_ERROR_500)
+                .body(containsString("500 Internal Server Error"));
+        }
+
+        @Test
+        void getShouldReturnErrorWhenRuntimeExceptionIsThrown() throws Exception {
+            doThrow(RuntimeException.class)
+                .when(memoryRecipientRewriteTable)
+                .getMappings(anyString(), any());
+
+            when()
+                .get(ALICE)
+            .then()
+                .statusCode(HttpStatus.INTERNAL_SERVER_ERROR_500)
+                .body(containsString("500 Internal Server Error"));
+        }
+    }
+
+}


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