Fix child collection bindings in RestApiServlet and add tests

The Gerrit Plugin injector and the RestApiServlet currently have
different bugs. This commit papers over the most pressing ones and adds
tests so that plugins can bind child collections and offer modifications
directly on the collections (e.g. postOnCollection).

When binding rest views from plugins, the plugin name is incorrectly set
to "gerrit" which is what we use for core Gerrit views. This does not
apply to collections though. These are bound with the correct plugin
name "plugin-name".

In RestApiServlet, this leads to a situation where the collection thinks
it belongs to a different part of Gerrit. For operations that modify the
collection (post, delete) this means we have to use "gerrit" as the
component name when looking up child views. For root collections, we
already do this and this is why it works for existing plugin root
collections. For subcollections, however, we did not - which is what
this commit changes. Now the two usages in RestApiServlet are
consistent.

To be clear: They are now consistently wrong across the different parts
of Gerrit, but they are consistent which means they work.

As future work, we want to fix this long standing issue by binding the
correct plugin names from the beginning. This is non-trivial though
because the DynamicMapProvider has no context on the plugin that is
performing the binding.

This commit adds the same logic that we have for core-collection
delegation to plugin-collections in RestApiServlet.

Change-Id: I954105155c4a354b6c577d8cc246d00998e33f57
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index 2b00d7c..d6dca48 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -392,8 +392,12 @@
             if (isRead(req)) {
               viewData = new ViewData(null, c.list());
             } else if (isPost(req)) {
+              // TODO: Here and on other collection methods: There is a bug that binds child views
+              // with pluginName="gerrit" instead of the real plugin name. This has never worked
+              // correctly and should be fixed where the binding gets created (DynamicMapProvider)
+              // and here.
               RestView<RestResource> restCollectionView =
-                  c.views().get(viewData.pluginName, "POST_ON_COLLECTION./");
+                  c.views().get(PluginName.GERRIT, "POST_ON_COLLECTION./");
               if (restCollectionView != null) {
                 viewData = new ViewData(null, restCollectionView);
               } else {
@@ -401,7 +405,7 @@
               }
             } else if (isDelete(req)) {
               RestView<RestResource> restCollectionView =
-                  c.views().get(viewData.pluginName, "DELETE_ON_COLLECTION./");
+                  c.views().get(PluginName.GERRIT, "DELETE_ON_COLLECTION./");
               if (restCollectionView != null) {
                 viewData = new ViewData(null, restCollectionView);
               } else {
@@ -1264,6 +1268,8 @@
       return new ViewData(PluginName.GERRIT, core);
     }
 
+    // Check if we want to delegate to a child collection. Child collections are bound with
+    // GET.name so we have to check for this since we haven't found any other views.
     core = views.get(PluginName.GERRIT, "GET." + p.get(0));
     if (core != null) {
       return new ViewData(PluginName.GERRIT, core);
@@ -1277,6 +1283,17 @@
       }
     }
 
+    if (r.isEmpty()) {
+      // Check if we want to delegate to a child collection. Child collections are bound with
+      // GET.name so we have to check for this since we haven't found any other views.
+      for (String plugin : views.plugins()) {
+        RestView<RestResource> action = views.get(plugin, "GET." + p.get(0));
+        if (action != null) {
+          r.put(plugin, action);
+        }
+      }
+    }
+
     if (r.size() == 1) {
       Map.Entry<String, RestView<RestResource>> entry = Iterables.getOnlyElement(r.entrySet());
       return new ViewData(entry.getKey(), entry.getValue());
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedChildRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedChildRestApiBindingsIT.java
new file mode 100644
index 0000000..27df565
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedChildRestApiBindingsIT.java
@@ -0,0 +1,160 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed 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 com.google.gerrit.acceptance.rest.binding;
+
+import static com.google.gerrit.server.change.RevisionResource.REVISION_KIND;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.rest.util.RestApiCallHelper;
+import com.google.gerrit.acceptance.rest.util.RestCall;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestApiModule;
+import com.google.gerrit.extensions.restapi.RestCollectionModifyView;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.extensions.restapi.RestResource;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import com.google.inject.TypeLiteral;
+import org.junit.Test;
+
+/**
+ * Tests for checking plugin-provided REST API bindings nested under a core collection.
+ *
+ * <p>These tests only verify that the plugin-provided REST endpoints are correctly bound, they do
+ * not test the functionality of the plugin REST endpoints.
+ */
+public class PluginProvidedChildRestApiBindingsIT extends AbstractDaemonTest {
+
+  /** Resource to bind a child collection. */
+  public static final TypeLiteral<RestView<TestPluginResource>> TEST_KIND =
+      new TypeLiteral<RestView<TestPluginResource>>() {};
+
+  private static final String PLUGIN_NAME = "my-plugin";
+
+  private static final ImmutableSet<RestCall> TEST_CALLS =
+      ImmutableSet.of(
+          // Calls that have the plugin name as part of the collection name
+          RestCall.get("/changes/%s/revisions/%s/" + PLUGIN_NAME + "~test-collection/"),
+          RestCall.get("/changes/%s/revisions/%s/" + PLUGIN_NAME + "~test-collection/1/detail"),
+          RestCall.post("/changes/%s/revisions/%s/" + PLUGIN_NAME + "~test-collection/"),
+          RestCall.post("/changes/%s/revisions/%s/" + PLUGIN_NAME + "~test-collection/1/update"),
+          // Same tests but without the plugin name as part of the collection name. This works as
+          // long as there is no core collection with the same name (which takes precedence) and no
+          // other plugin binds a collection with the same name. We highly encourage plugin authors
+          // to use the fully qualified collection name instead.
+          RestCall.get("/changes/%s/revisions/%s/test-collection/"),
+          RestCall.get("/changes/%s/revisions/%s/test-collection/1/detail"),
+          RestCall.post("/changes/%s/revisions/%s/test-collection/"),
+          RestCall.post("/changes/%s/revisions/%s/test-collection/1/update"));
+
+  /**
+   * Module for all sys bindings.
+   *
+   * <p>TODO: This should actually just move into MyPluginHttpModule. However, that doesn't work
+   * currently. This TODO is for fixing this bug.
+   */
+  static class MyPluginSysModule extends AbstractModule {
+    @Override
+    public void configure() {
+      install(
+          new RestApiModule() {
+            @Override
+            public void configure() {
+              DynamicMap.mapOf(binder(), TEST_KIND);
+              child(REVISION_KIND, "test-collection").to(TestChildCollection.class);
+
+              postOnCollection(TEST_KIND).to(TestPostOnCollection.class);
+              post(TEST_KIND, "update").to(TestPost.class);
+              get(TEST_KIND, "detail").to(TestGet.class);
+            }
+          });
+    }
+  }
+
+  static class TestPluginResource implements RestResource {}
+
+  @Singleton
+  static class TestChildCollection
+      implements ChildCollection<RevisionResource, TestPluginResource> {
+    private final DynamicMap<RestView<TestPluginResource>> views;
+
+    @Inject
+    TestChildCollection(DynamicMap<RestView<TestPluginResource>> views) {
+      this.views = views;
+    }
+
+    @Override
+    public RestView<RevisionResource> list() throws RestApiException {
+      return (RestReadView<RevisionResource>) resource -> ImmutableList.of("one", "two");
+    }
+
+    @Override
+    public TestPluginResource parse(RevisionResource parent, IdString id) throws Exception {
+      return new TestPluginResource();
+    }
+
+    @Override
+    public DynamicMap<RestView<TestPluginResource>> views() {
+      return views;
+    }
+  }
+
+  @Singleton
+  static class TestPostOnCollection
+      implements RestCollectionModifyView<RevisionResource, TestPluginResource, String> {
+    @Override
+    public Object apply(RevisionResource parentResource, String input) throws Exception {
+      return "test";
+    }
+  }
+
+  @Singleton
+  static class TestPost implements RestModifyView<TestPluginResource, String> {
+    @Override
+    public String apply(TestPluginResource resource, String input) throws Exception {
+      return "test";
+    }
+  }
+
+  @Singleton
+  static class TestGet implements RestReadView<TestPluginResource> {
+    @Override
+    public String apply(TestPluginResource resource) throws Exception {
+      return "test";
+    }
+  }
+
+  @Test
+  public void testEndpoints() throws Exception {
+    PatchSet.Id patchSetId = createChange().getPatchSetId();
+    try (AutoCloseable ignored = installPlugin(PLUGIN_NAME, MyPluginSysModule.class, null, null)) {
+      RestApiCallHelper.execute(
+          adminRestSession,
+          TEST_CALLS.asList(),
+          String.valueOf(patchSetId.changeId.id),
+          String.valueOf(patchSetId.patchSetId));
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedRestApiBindingsIT.java
deleted file mode 100644
index 82c065a..0000000
--- a/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedRestApiBindingsIT.java
+++ /dev/null
@@ -1,75 +0,0 @@
-// Copyright (C) 2019 The Android Open Source Project
-//
-// Licensed 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 com.google.gerrit.acceptance.rest.binding;
-
-import static javax.servlet.http.HttpServletResponse.SC_OK;
-
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.rest.util.RestApiCallHelper;
-import com.google.gerrit.acceptance.rest.util.RestCall;
-import com.google.inject.Singleton;
-import com.google.inject.servlet.ServletModule;
-import java.io.IOException;
-import javax.servlet.http.HttpServlet;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-import org.junit.Test;
-
-/**
- * Tests for checking plugin-provided REST API bindings.
- *
- * <p>These tests only verify that the plugin-provided REST endpoints are correctly bound, they do
- * not test the functionality of the plugin REST endpoints.
- */
-public class PluginProvidedRestApiBindingsIT extends AbstractDaemonTest {
-
-  /**
-   * Plugin REST endpoints bound by {@link MyPluginModule} with Guice serlvet definitions.
-   *
-   * <p>Each URL contains a placeholder for the plugin identifier.
-   *
-   * <p>Currently does not include any resource or documentation URLs, since those would require
-   * installing a plugin from a jar, which is trickier than just defining a module in this file.
-   */
-  private static final ImmutableList<RestCall> SERVER_TOP_LEVEL_PLUGIN_ENDPOINTS =
-      ImmutableList.of(RestCall.get("/plugins/%s/hello"));
-
-  static class MyPluginModule extends ServletModule {
-    @Override
-    public void configureServlets() {
-      serve("/hello").with(HelloServlet.class);
-    }
-  }
-
-  @Singleton
-  static class HelloServlet extends HttpServlet {
-    private static final long serialVersionUID = 1L;
-
-    @Override
-    protected void doGet(HttpServletRequest req, HttpServletResponse res) throws IOException {
-      res.setStatus(SC_OK);
-      res.getWriter().println("Hello world");
-    }
-  }
-
-  @Test
-  public void serverPluginTopLevelEndpoints() throws Exception {
-    String pluginName = "my-plugin";
-    try (AutoCloseable ignored = installPlugin(pluginName, null, MyPluginModule.class, null)) {
-      RestApiCallHelper.execute(adminRestSession, SERVER_TOP_LEVEL_PLUGIN_ENDPOINTS, pluginName);
-    }
-  }
-}
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedRootRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedRootRestApiBindingsIT.java
new file mode 100644
index 0000000..8a8ea9b
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedRootRestApiBindingsIT.java
@@ -0,0 +1,199 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed 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 com.google.gerrit.acceptance.rest.binding;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.rest.util.RestApiCallHelper;
+import com.google.gerrit.acceptance.rest.util.RestCall;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestApiModule;
+import com.google.gerrit.extensions.restapi.RestCollectionModifyView;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.extensions.restapi.RestResource;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.httpd.restapi.RestApiServlet;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import com.google.inject.TypeLiteral;
+import com.google.inject.servlet.ServletModule;
+import java.io.IOException;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletRequestWrapper;
+import javax.servlet.http.HttpServletResponse;
+import org.junit.Test;
+
+/**
+ * Tests for checking plugin-provided REST API bindings directly under {@code /}.
+ *
+ * <p>These tests only verify that the plugin-provided REST endpoints are correctly bound, they do
+ * not test the functionality of the plugin REST endpoints.
+ */
+public class PluginProvidedRootRestApiBindingsIT extends AbstractDaemonTest {
+
+  /** Resource to bind a child collection. */
+  public static final TypeLiteral<RestView<TestPluginResource>> TEST_KIND =
+      new TypeLiteral<RestView<TestPluginResource>>() {};
+
+  private static final String PLUGIN_NAME = "my-plugin";
+
+  private static final ImmutableSet<RestCall> TEST_CALLS =
+      ImmutableSet.of(
+          RestCall.get("/plugins/" + PLUGIN_NAME + "/test-collection/"),
+          RestCall.get("/plugins/" + PLUGIN_NAME + "/test-collection/1/detail"),
+          RestCall.post("/plugins/" + PLUGIN_NAME + "/test-collection/"),
+          RestCall.post("/plugins/" + PLUGIN_NAME + "/test-collection/1/update"));
+
+  /** Module for all HTTP bindings. */
+  static class MyPluginHttpModule extends ServletModule {
+    @Override
+    public void configureServlets() {
+      bind(TestRootCollection.class);
+
+      install(
+          new RestApiModule() {
+            @Override
+            public void configure() {
+              DynamicMap.mapOf(binder(), TEST_KIND);
+
+              postOnCollection(TEST_KIND).to(TestPostOnCollection.class);
+              post(TEST_KIND, "update").to(TestPost.class);
+              get(TEST_KIND, "detail").to(TestGet.class);
+            }
+          });
+
+      serveRegex("/(?:a/)?test-collection/(.*)$").with(TestRestApiServlet.class);
+    }
+  }
+
+  @Singleton
+  static class TestRestApiServlet extends RestApiServlet {
+    private static final long serialVersionUID = 1L;
+
+    @Inject
+    TestRestApiServlet(RestApiServlet.Globals globals, Provider<TestRootCollection> collection) {
+      super(globals, collection);
+    }
+
+    @Override
+    public void service(ServletRequest servletRequest, ServletResponse servletResponse)
+        throws ServletException, IOException {
+      // This is...unfortunate. HttpPluginServlet (and/or ContextMapper) doesn't properly set the
+      // servlet path on the wrapped request. Based on what RestApiServlet produces for non-plugin
+      // requests, it should be:
+      //   contextPath = "/plugins/checks"
+      //   servletPath = "/checkers/"
+      //   pathInfo = checkerUuid
+      // Instead it does:
+      //   contextPath = "/plugins/checks"
+      //   servletPath = ""
+      //   pathInfo = "/checkers/" + checkerUuid
+      // This results in RestApiServlet splitting the pathInfo into ["", "checkers", checkerUuid],
+      // and it passes the "" to CheckersCollection#parse, which understandably, but unfortunately,
+      // fails.
+      //
+      // This frankly seems like a bug that should be fixed, but it would quite likely break
+      // existing plugins in confusing ways. So, we work around it by introducing our own request
+      // wrapper with the correct paths.
+      HttpServletRequest req = (HttpServletRequest) servletRequest;
+      String pathInfo = req.getPathInfo();
+      String correctServletPath = "/test-collection/";
+      String fixedPathInfo = pathInfo.substring(correctServletPath.length());
+      HttpServletRequestWrapper wrapped =
+          new HttpServletRequestWrapper(req) {
+            @Override
+            public String getServletPath() {
+              return correctServletPath;
+            }
+
+            @Override
+            public String getPathInfo() {
+              return fixedPathInfo;
+            }
+          };
+
+      super.service(wrapped, (HttpServletResponse) servletResponse);
+    }
+  }
+
+  static class TestPluginResource implements RestResource {}
+
+  @Singleton
+  static class TestRootCollection implements ChildCollection<TopLevelResource, TestPluginResource> {
+    private final DynamicMap<RestView<TestPluginResource>> views;
+
+    @Inject
+    TestRootCollection(DynamicMap<RestView<TestPluginResource>> views) {
+      this.views = views;
+    }
+
+    @Override
+    public RestView<TopLevelResource> list() throws RestApiException {
+      return (RestReadView<TopLevelResource>) resource -> ImmutableList.of("one", "two");
+    }
+
+    @Override
+    public TestPluginResource parse(TopLevelResource parent, IdString id) throws Exception {
+      return new TestPluginResource();
+    }
+
+    @Override
+    public DynamicMap<RestView<TestPluginResource>> views() {
+      return views;
+    }
+  }
+
+  @Singleton
+  static class TestPostOnCollection
+      implements RestCollectionModifyView<TopLevelResource, TestPluginResource, String> {
+    @Override
+    public Object apply(TopLevelResource parentResource, String input) throws Exception {
+      return "test";
+    }
+  }
+
+  @Singleton
+  static class TestPost implements RestModifyView<TestPluginResource, String> {
+    @Override
+    public String apply(TestPluginResource resource, String input) throws Exception {
+      return "test";
+    }
+  }
+
+  @Singleton
+  static class TestGet implements RestReadView<TestPluginResource> {
+    @Override
+    public String apply(TestPluginResource resource) throws Exception {
+      return "test";
+    }
+  }
+
+  @Test
+  public void testEndpoints() throws Exception {
+    try (AutoCloseable ignored = installPlugin(PLUGIN_NAME, null, MyPluginHttpModule.class, null)) {
+      RestApiCallHelper.execute(adminRestSession, TEST_CALLS.asList());
+    }
+  }
+}