Implement go-import filter

The filter acts on http requests with go-get=1 as parameter.
This request is usually issued by go command [1]. The response
contains meta-data indicating the repo location on the server.
More discussion about the feature is available in RFC [2].

[1] https://golang.org/cmd/go/#hdr-Remote_import_paths
[2] https://gerrit-review.googlesource.com/#/c/98371/

Change-Id: I684f378d710bd8150bbd27dd589d1a796e12f920
diff --git a/.buckconfig b/.buckconfig
new file mode 100644
index 0000000..c02861b
--- /dev/null
+++ b/.buckconfig
@@ -0,0 +1,15 @@
+[alias]
+  go-import = //:go-import
+  plugin = //:go-import
+  src = //:go-import-sources
+
+[java]
+  src_roots = java, resources
+
+[project]
+  ignore = .git
+
+[cache]
+  mode = dir
+  dir = buck-out/cache
+
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..39971b0
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+/.buckversion
+/.buckd
+/buck-out
+/bucklets
+/target
+/.classpath
+/.project
+/.settings
diff --git a/BUCK b/BUCK
new file mode 100644
index 0000000..b43a5f7
--- /dev/null
+++ b/BUCK
@@ -0,0 +1,67 @@
+include_defs('//bucklets/gerrit_plugin.bucklet')
+include_defs('//bucklets/java_sources.bucklet')
+include_defs('//bucklets/maven_jar.bucklet')
+
+SOURCES = glob(['src/main/java/**/*.java'])
+RESOURCES = glob(['src/main/resources/**/*'])
+
+TEST_DEPS = GERRIT_PLUGIN_API + GERRIT_TESTS + [
+   ':go-import__plugin',
+   ':mockito',
+]
+
+gerrit_plugin(
+   name = 'go-import',
+   srcs = SOURCES,
+   resources = RESOURCES,
+   manifest_entries = [
+     'Gerrit-PluginName: go-import',
+     'Gerrit-ApiType: plugin',
+     'Gerrit-HttpModule: com.ericsson.gerrit.plugins.goimport.HttpModule',
+     'Implementation-Title: go-import plugin',
+     'Implementation-URL: https://gerrit-review.googlesource.com/#/admin/projects/plugins/go-import',
+     'Implementation-Vendor: Ericsson',
+   ],
+)
+
+java_library(
+   name = 'classpath',
+   deps = TEST_DEPS,
+)
+
+java_test(
+   name = 'go-import_tests',
+   labels = ['go-import'],
+   srcs = glob(['src/test/java/**/*.java']),
+   deps = TEST_DEPS,
+)
+
+java_sources(
+   name = 'go-import-sources',
+   srcs = SOURCES + RESOURCES,
+)
+
+maven_jar(
+  name = 'mockito',
+  id = 'org.mockito:mockito-core:2.7.19',
+  sha1 = '9e0dbe97eca58ef4c26e3b9e1a12ee42e76a63a5',
+  license = 'DO_NOT_DISTRIBUTE',
+  deps = [
+    ':byte-buddy',
+    ':objenesis',
+  ],
+)
+maven_jar(
+  name = 'byte-buddy',
+  id = 'net.bytebuddy:byte-buddy:1.6.11',
+  sha1 = '8a8f9409e27f1d62c909c7eef2aa7b3a580b4901',
+  license = 'DO_NOT_DISTRIBUTE',
+  attach_source = False,
+)
+maven_jar(
+  name = 'objenesis',
+  id = 'org.objenesis:objenesis:2.5',
+  sha1 = '612ecb799912ccf77cba9b3ed8c813da086076e9',
+  license = 'DO_NOT_DISTRIBUTE',
+  attach_source = False,
+)
diff --git a/lib/gerrit/BUCK b/lib/gerrit/BUCK
new file mode 100644
index 0000000..78a5bdf
--- /dev/null
+++ b/lib/gerrit/BUCK
@@ -0,0 +1,22 @@
+include_defs('//bucklets/maven_jar.bucklet')
+
+VER = '2.12.5'
+REPO = MAVEN_CENTRAL
+
+maven_jar(
+  name = 'plugin-api',
+  id = 'com.google.gerrit:gerrit-plugin-api:' + VER,
+  sha1 = '456b8ed836cdcba672f94f397f09a67bcfbe54a7',
+  attach_source = False,
+  repository = REPO,
+  license = 'Apache2.0',
+)
+
+maven_jar(
+   name = 'acceptance-framework',
+   id = 'com.google.gerrit:gerrit-acceptance-framework:' + VER,
+   sha1 = 'b5de4458e29975bcd5f318411dddbef67063ec00',
+   license = 'Apache2.0',
+   attach_source = False,
+   repository = REPO,
+)
\ No newline at end of file
diff --git a/src/main/java/com/ericsson/gerrit/plugins/goimport/GoImportFilter.java b/src/main/java/com/ericsson/gerrit/plugins/goimport/GoImportFilter.java
new file mode 100644
index 0000000..e7d50ab
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/goimport/GoImportFilter.java
@@ -0,0 +1,133 @@
+// Copyright (C) 2017 Ericsson
+//
+// 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.ericsson.gerrit.plugins.goimport;
+
+import com.google.gerrit.httpd.AllRequestFilter;
+import com.google.gerrit.httpd.HtmlDomUtil;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.config.CanonicalWebUrl;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gwtexpui.server.CacheHeaders;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+@Singleton
+public class GoImportFilter extends AllRequestFilter {
+
+  private static final String PAGE_404 = "<!DOCTYPE html>\n"
+      + "<html>\n"
+      + "<head>\n"
+      + "  <title>Gerrit-Go-Import</title>\n"
+      + "  <meta http-equiv=\"Content-Type\" content=\"text/html; charset=UTF-8\"/>\n"
+      + "</head>\n"
+      + "<body>\n"
+      + "NOT FOUND\n"
+      + "</body>\n"
+      + "</html>";
+
+  private static final String PAGE_200 = "<!DOCTYPE html>\n"
+      + "<html>\n"
+      + "<head>\n"
+      + "  <title>Gerrit-Go-Import</title>\n"
+      + "  <meta http-equiv=\"Content-Type\" content=\"text/html; charset=UTF-8\"/>\n"
+      + "  <meta name=\"go-import\" content=\"${content}\"/>\n"
+      + "</head>\n"
+      + "<body>\n"
+      + "<div>\n"
+      + "  Gerrit-Go-Import\n"
+      + "</div>\n"
+      + "</body>\n"
+      + "</html>";
+
+  private final ProjectCache projectCache;
+  final String webUrl;
+  final String projectPrefix;
+
+  @Inject
+  GoImportFilter(ProjectCache projectCache,
+      @CanonicalWebUrl String webUrl)
+      throws URISyntaxException {
+    this.projectCache = projectCache;
+    this.webUrl = webUrl.replaceFirst("/?$", "/");
+    this.projectPrefix = generateProjectPrefix();
+  }
+
+  private String generateProjectPrefix() throws URISyntaxException {
+    URI uri = new URI(webUrl);
+    return uri.getHost() + (uri.getPort() == -1 ? "" : ":" + uri.getPort())
+        + uri.getPath();
+  }
+
+  @Override
+  public void doFilter(ServletRequest request, ServletResponse response,
+      FilterChain chain) throws IOException, ServletException {
+    if (request instanceof HttpServletRequest) {
+      HttpServletRequest req = (HttpServletRequest) request;
+      HttpServletResponse rsp = (HttpServletResponse) response;
+      String servletPath = req.getServletPath();
+      String goGet = req.getParameter("go-get");
+      if ("1".equals(goGet)) {
+        String projectName = getProjectName(servletPath);
+
+        byte[] tosend = PAGE_404.getBytes();
+        rsp.setStatus(404);
+        if (projectExists(projectName)) {
+          tosend = PAGE_200.replace("${content}", getContent(projectName))
+              .getBytes();
+          rsp.setStatus(200);
+        }
+
+        CacheHeaders.setNotCacheable(rsp);
+        rsp.setContentType("text/html");
+        rsp.setCharacterEncoding(HtmlDomUtil.ENC.name());
+        rsp.setContentLength(tosend.length);
+        try (OutputStream out = rsp.getOutputStream()) {
+          out.write(tosend);
+        }
+      } else {
+        chain.doFilter(request, response);
+      }
+    } else {
+      chain.doFilter(request, response);
+    }
+
+  }
+
+  private String getProjectName(String servletPath) {
+    return servletPath.replaceFirst("/", "");
+  }
+
+  private CharSequence getContent(String projectName) {
+    return projectPrefix + projectName + " git " + webUrl  + "a/" + projectName;
+  }
+
+  private boolean projectExists(String projectName) {
+    ProjectState p = projectCache.get(new Project.NameKey(projectName));
+    return p != null;
+  }
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/goimport/HttpModule.java b/src/main/java/com/ericsson/gerrit/plugins/goimport/HttpModule.java
new file mode 100644
index 0000000..789d795
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/goimport/HttpModule.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2017 Ericsson
+//
+// 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.ericsson.gerrit.plugins.goimport;
+
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.httpd.AllRequestFilter;
+import com.google.inject.Scopes;
+import com.google.inject.servlet.ServletModule;
+
+class HttpModule extends ServletModule {
+  @Override
+  protected void configureServlets() {
+    DynamicSet.bind(binder(), AllRequestFilter.class).to(GoImportFilter.class)
+        .in(Scopes.SINGLETON);
+  }
+}
diff --git a/src/main/resources/Documentation/about.md b/src/main/resources/Documentation/about.md
new file mode 100644
index 0000000..41a5908
--- /dev/null
+++ b/src/main/resources/Documentation/about.md
@@ -0,0 +1,25 @@
+This plugin provides support of
+[remote import paths](https://golang.org/cmd/go/#hdr-Remote_import_paths) and
+[go-get command](https://golang.org/cmd/go/).
+
+### Usage
+
+* Go-get command
+
+```
+go get example.org/foo
+```
+
+* Remote import paths
+
+```
+import "example.org/foo"
+```
+
+### Limitation
+Folder of the repository is not supported in the context of Gerrit up to
+ambiguity of repository name. E.g., _example.org/foo/bar_ has two potential meanings:
+
+* Folder _bar_ in repository _foo_, or
+* repository _foo/bar_.
+
diff --git a/src/main/resources/Documentation/build.md b/src/main/resources/Documentation/build.md
new file mode 100644
index 0000000..ddbf282
--- /dev/null
+++ b/src/main/resources/Documentation/build.md
@@ -0,0 +1,105 @@
+Build
+=====
+
+This plugin is built with Buck.
+
+Two build modes are supported: Standalone and in Gerrit tree. Standalone
+build mode is recommended, as this mode doesn't require local Gerrit
+tree to exist.
+
+Build standalone
+----------------
+
+Clone bucklets library:
+
+```
+  git clone https://gerrit.googlesource.com/bucklets
+```
+and link it to @PLUGIN@ directory:
+
+```
+  cd @PLUGIN@ && ln -s ../bucklets .
+```
+
+Add link to the .buckversion file:
+
+```
+  cd @PLUGIN@ && ln -s bucklets/buckversion .buckversion
+```
+
+Add link to the .watchmanconfig file:
+
+```
+  cd @PLUGIN@ && ln -s bucklets/watchmanconfig .watchmanconfig
+```
+
+To build the plugin, issue the following command:
+
+```
+  buck build plugin
+```
+
+The output is created in:
+
+```
+  buck-out/gen/@PLUGIN@.jar
+```
+
+This project can be imported into the Eclipse IDE:
+
+```
+  ./bucklets/tools/eclipse.py
+```
+
+To execute the tests run:
+
+```
+  buck test
+```
+
+To build plugin sources run:
+
+```
+  buck build src
+```
+
+The output is created in:
+
+```
+  buck-out/gen/@PLUGIN@-sources.jar
+```
+
+Build in Gerrit tree
+--------------------
+
+Clone or link this plugin to the plugins directory of Gerrit's source
+tree, and issue the command:
+
+```
+  buck build plugins/@PLUGIN@
+```
+
+The output is created in:
+
+```
+  buck-out/gen/plugins/@PLUGIN@/@PLUGIN@.jar
+```
+
+This project can be imported into the Eclipse IDE:
+
+```
+  ./tools/eclipse/project.py
+```
+
+To execute the tests run:
+
+```
+  buck test --include @PLUGIN@
+```
+
+How to build the Gerrit Plugin API is described in the [Gerrit
+documentation](../../../Documentation/dev-buck.html#_extension_and_plugin_api_jar_files).
+
+[Back to @PLUGIN@ documentation index][index]
+
+[index]: index.html
diff --git a/src/test/java/com/ericsson/gerrit/plugins/goimport/GoImportFilterTest.java b/src/test/java/com/ericsson/gerrit/plugins/goimport/GoImportFilterTest.java
new file mode 100644
index 0000000..405628d
--- /dev/null
+++ b/src/test/java/com/ericsson/gerrit/plugins/goimport/GoImportFilterTest.java
@@ -0,0 +1,171 @@
+// Copyright (C) 2017 Ericsson
+//
+// 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.ericsson.gerrit.plugins.goimport;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+import static org.mockito.Mockito.*;
+
+import com.ericsson.gerrit.plugins.goimport.GoImportFilter;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+
+import java.io.IOException;
+import java.net.URISyntaxException;
+
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.ServletOutputStream;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+@RunWith(MockitoJUnitRunner.class)
+public class GoImportFilterTest {
+
+  private static final String PROD_URL = "https://gerrit-review.googlesource.com";
+  private static final String PAGE_200 = "<!DOCTYPE html>\n"
+      + "<html>\n"
+      + "<head>\n"
+      + "  <title>Gerrit-Go-Import</title>\n"
+      + "  <meta http-equiv=\"Content-Type\" content=\"text/html; charset=UTF-8\"/>\n"
+      + "  <meta name=\"go-import\" content=\"gerrit-review.googlesource.com/projectName git https://gerrit-review.googlesource.com/a/projectName\"/>\n"
+      + "</head>\n"
+      + "<body>\n"
+      + "<div>\n"
+      + "  Gerrit-Go-Import\n"
+      + "</div>\n"
+      + "</body>\n"
+      + "</html>";
+
+  private GoImportFilter unitUnderTest;
+
+  @Mock
+  private ProjectCache mockProjectCache;
+  @Mock
+  private HttpServletRequest mockRequest;
+  @Mock
+  private HttpServletResponse mockResponse;
+  @Mock
+  private FilterChain mockChain;
+  @Mock
+  private ServletOutputStream mockOutputStream;
+  @Mock
+  private ProjectState mockProjectState;
+
+  @Before
+  public void setUp() throws Exception {
+    unitUnderTest = new GoImportFilter(mockProjectCache, PROD_URL);
+    assertThat(unitUnderTest).isNotNull();
+    when(mockResponse.getOutputStream()).thenReturn(mockOutputStream);
+  }
+
+  @Test
+  public void testConstructor() throws Exception {
+    assertThat(unitUnderTest.webUrl.endsWith("/")).isTrue();
+    unitUnderTest = new GoImportFilter(mockProjectCache,
+        "http://gerrit-review.googlesource.com:8080/");
+    assertThat(unitUnderTest.webUrl.endsWith("/")).isTrue();
+    assertThat(unitUnderTest.projectPrefix).isNotNull();
+  }
+
+  @Test(expected=URISyntaxException.class)
+  public void testConstructorWithURISyntaxException() throws Exception {
+    unitUnderTest = new GoImportFilter(mockProjectCache, "\\\\");
+  }
+
+  @Test
+  public void testDoFilterWithNull() throws Exception {
+    unitUnderTest.doFilter(null, null, mockChain);
+    verify(mockChain, times(1)).doFilter(null, null);
+  }
+
+  @Test
+  public void testDoFilterWithoutGoGetParameter() throws Exception {
+    when(mockRequest.getServletPath()).thenReturn("/projectName");
+    when(mockRequest.getParameter("go-get")).thenReturn(null);
+    unitUnderTest.doFilter(mockRequest, mockResponse, mockChain);
+    verify(mockOutputStream, times(0)).write(any(byte[].class));
+    verify(mockChain, times(1)).doFilter(mockRequest, mockResponse);
+  }
+
+  @Test
+  public void testDoFilterWithWrongGoGetParameterValue() throws Exception {
+    when(mockRequest.getServletPath()).thenReturn("/projectName");
+    when(mockRequest.getParameter("go-get")).thenReturn("2");
+    unitUnderTest.doFilter(mockRequest, mockResponse, mockChain);
+    verify(mockOutputStream, times(0)).write(any(byte[].class));
+    verify(mockChain, times(1)).doFilter(mockRequest, mockResponse);
+  }
+
+  @Test
+  public void testDoFilterWithExistingProject() throws Exception {
+    when(mockRequest.getServletPath()).thenReturn("/projectName");
+    when(mockRequest.getParameter("go-get")).thenReturn("1");
+    when(mockProjectCache.get(any(Project.NameKey.class))).thenReturn(mockProjectState);
+    unitUnderTest.doFilter(mockRequest, mockResponse, mockChain);
+    verify(mockOutputStream, times(1)).write(PAGE_200.getBytes());
+    verify(mockChain, times(0)).doFilter(mockRequest, mockResponse);
+    verify(mockProjectCache, times(1)).get(any(Project.NameKey.class));
+    verify(mockResponse, times(1)).setStatus(200);
+  }
+
+  @Test
+  public void testDoFilterWithNonExistingProject() throws Exception {
+    when(mockRequest.getServletPath()).thenReturn("/projectName");
+    when(mockRequest.getParameter("go-get")).thenReturn("1");
+    when(mockProjectCache.get(any(Project.NameKey.class))).thenReturn(null);
+    unitUnderTest.doFilter(mockRequest, mockResponse, mockChain);
+    verify(mockOutputStream, times(1)).write(any(byte[].class));
+    verify(mockChain, times(0)).doFilter(mockRequest, mockResponse);
+    verify(mockProjectCache, times(1)).get(any(Project.NameKey.class));
+    verify(mockResponse, times(1)).setStatus(404);
+  }
+
+  @Test
+  public void testDoFilterWithIOException() throws Exception {
+    String msg = "test-io-error";
+    when(mockRequest.getServletPath()).thenReturn("/projectName");
+    when(mockRequest.getParameter("go-get")).thenReturn("1");
+    doThrow(new IOException(msg)).when(mockOutputStream).write(any(byte[].class));
+    when(mockProjectCache.get(any(Project.NameKey.class))).thenReturn(mockProjectState);
+    try {
+      unitUnderTest.doFilter(mockRequest, mockResponse, mockChain);
+      fail("IOException should occur!");
+    } catch(IOException e) {
+      assertThat(msg).isEqualTo(e.getMessage());
+      verify(mockOutputStream, times(1)).write(any(byte[].class));
+    }
+  }
+
+  @Test
+  public void testDoFilterWithServletException() throws Exception {
+    String msg = "test-serv-error";
+    doThrow(new ServletException(msg)).when(mockChain).doFilter(null, null);
+    try {
+      unitUnderTest.doFilter(null, null, mockChain);
+      fail("ServletException should occur!");
+    } catch(ServletException e) {
+      assertThat(msg).isEqualTo(e.getMessage());
+      verify(mockChain, times(1)).doFilter(null, null);
+    }
+  }
+}
diff --git a/src/test/java/com/ericsson/gerrit/plugins/goimport/HttpModuleTest.java b/src/test/java/com/ericsson/gerrit/plugins/goimport/HttpModuleTest.java
new file mode 100644
index 0000000..adec853
--- /dev/null
+++ b/src/test/java/com/ericsson/gerrit/plugins/goimport/HttpModuleTest.java
@@ -0,0 +1,69 @@
+// Copyright (C) 2017 Ericsson
+//
+// 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.ericsson.gerrit.plugins.goimport;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.ericsson.gerrit.plugins.goimport.GoImportFilter;
+import com.ericsson.gerrit.plugins.goimport.HttpModule;
+import com.google.gerrit.server.config.CanonicalWebUrl;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.inject.AbstractModule;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import com.google.inject.Provides;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+
+@RunWith(MockitoJUnitRunner.class)
+public class HttpModuleTest {
+
+  private HttpModule unitUnderTest;
+  @Mock
+  private ProjectCache mockProjectCache;
+
+  @Before
+  public void setUp() throws Exception {
+    unitUnderTest = new HttpModule();
+    assertThat(unitUnderTest).isNotNull();
+  }
+
+  @Test
+  public void testConfigureServlets() throws Exception {
+    Injector injector = Guice.createInjector(unitUnderTest, new TestModule());
+
+    GoImportFilter filter1 = injector.getInstance(GoImportFilter.class);
+    assertThat(filter1).isNotNull();
+    GoImportFilter filter2 = injector.getInstance(GoImportFilter.class);
+    assertThat(filter1).isSameAs(filter2);
+  }
+
+  public class TestModule extends AbstractModule {
+    @Override
+    protected void configure() {
+      bind(ProjectCache.class).toInstance(mockProjectCache);
+    }
+
+    @Provides
+    @CanonicalWebUrl
+    String url() {
+      return "url";
+    }
+  }
+}