Support plugin fields in GetChange

Plugins implementing the existing ChangeAttributeFactory interface will
now get their fields populated on GET /changes/X and /changes/X/detail.
As before, the docs recommend guarding plugin-defined attribute
computation with @Options defined in a DynamicBean. Plugins that are
already doing this will see no behavior change on the Get Change
endpoints until they start passing their existing options.

Treat DynamicBeans for /detail the same as for /change without requiring
plugin authors to bind their DynamicBeans twice with two different
export names.

Change-Id: I9254d46b36ff0f359551c1ed334687fd14538973
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index 3517e22..00e153b 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -860,14 +860,14 @@
 ----
 
 [[query_attributes]]
-=== Query Attributes ===
+=== Change Attributes ===
 
-Plugins can provide additional attributes to be returned in Gerrit queries by
-implementing the `ChangeAttributeFactory` interface and registering it to the
-class in the plugin module's `configure()` method. The new attribute(s) will be
-output under a `plugin` attribute in the change query output. This can be
-further controlled with an option registered in the Http and Ssh modules'
-`configure*()` methods.
+Plugins can provide additional attributes to be returned from the Get Change and
+Query Change APIs by implementing implementing the `ChangeAttributeFactory`
+interface and registering it to the class in the plugin module's `configure()`
+method. The new attribute(s) will be output under a `plugin` attribute in the
+change output. This can be further controlled with an option registered in
+the Http and Ssh modules' `configure*()` methods.
 
 The example below shows a plugin that adds two attributes (`exampleName` and
 `changeValue`), to the change query output, when the query command is provided
@@ -884,7 +884,7 @@
   }
 }
 
-public class MyQueryOptions implements DynamicBean {
+public class MyChangeOptions implements DynamicBean {
   @Option(name = "--all", usage = "Include plugin output")
   public boolean all = false;
 }
@@ -893,8 +893,11 @@
   @Override
   protected void configureServlets() {
     bind(DynamicBean.class)
+        .annotatedWith(Exports.named(GetChange.class))
+        .to(MyChangeOptions.class);
+    bind(DynamicBean.class)
         .annotatedWith(Exports.named(QueryChanges.class))
-        .to(MyQueryOptions.class);
+        .to(MyChangeOptions.class);
   }
 }
 
@@ -903,12 +906,12 @@
   protected void configureCommands() {
     bind(DynamicBean.class)
         .annotatedWith(Exports.named(Query.class))
-        .to(MyQueryOptions.class);
+        .to(MyChangeOptions.class);
   }
 }
 
 public class AttributeFactory implements ChangeAttributeFactory {
-  protected MyQueryOptions options;
+  protected MyChangeOptions options;
 
   public class PluginAttribute extends PluginDefinedInfo {
     public String exampleName;
@@ -923,7 +926,7 @@
   @Override
   public PluginDefinedInfo create(ChangeData c, BeanProvider bp, String plugin) {
     if (options == null) {
-      options = (MyQueryOptions) bp.getDynamicBean(plugin);
+      options = (MyChangeOptions) bp.getDynamicBean(plugin);
     }
     if (options.all) {
       return new PluginAttribute(c);
@@ -951,6 +954,23 @@
    ],
     ...
 }
+
+curl http://localhost:8080/changes/1?myplugin-name--all
+
+Output:
+
+{
+  "_number": 1,
+  ...
+  "plugins": [
+    {
+      "name": "myplugin-name",
+      "example_name": "Attribute Example",
+      "change_value": "1"
+    }
+  ],
+  ...
+}
 ----
 
 [[simple-configuration]]
diff --git a/java/com/google/gerrit/extensions/registration/Extension.java b/java/com/google/gerrit/extensions/registration/Extension.java
index aaec201..1031bb0 100644
--- a/java/com/google/gerrit/extensions/registration/Extension.java
+++ b/java/com/google/gerrit/extensions/registration/Extension.java
@@ -32,7 +32,7 @@
   private final @Nullable String exportName;
   private final Provider<T> provider;
 
-  protected Extension(String pluginName, Provider<T> provider) {
+  public Extension(String pluginName, Provider<T> provider) {
     this(pluginName, null, provider);
   }
 
diff --git a/java/com/google/gerrit/server/DynamicOptions.java b/java/com/google/gerrit/server/DynamicOptions.java
index 2ae6899..44d3493 100644
--- a/java/com/google/gerrit/server/DynamicOptions.java
+++ b/java/com/google/gerrit/server/DynamicOptions.java
@@ -153,6 +153,17 @@
    */
   public interface BeanReceiver {
     void setDynamicBean(String plugin, DynamicBean dynamicBean);
+
+    /**
+     * Returns the class that should be used for looking up exported DynamicBean bindings from
+     * plugins. Override when a particular REST/SSH endpoint should respect DynamicBeans bound on a
+     * different endpoint. For example, {@code GetDetail} is just a synonym for a variant of {@code
+     * GetChange}, and it should respect any DynamicBeans on GetChange. GetChange}. So it should
+     * return {@code GetChange.class} from this method.
+     */
+    default Class<? extends BeanReceiver> getExportedBeanReceiver() {
+      return getClass();
+    }
   }
 
   public interface BeanProvider {
@@ -199,9 +210,13 @@
     this.bean = bean;
     this.injector = injector;
     beansByPlugin = new HashMap<>();
+    Class<?> beanClass =
+        (bean instanceof BeanReceiver)
+            ? ((BeanReceiver) bean).getExportedBeanReceiver()
+            : getClass();
     for (String plugin : dynamicBeans.plugins()) {
       Provider<DynamicBean> provider =
-          dynamicBeans.byPlugin(plugin).get(bean.getClass().getCanonicalName());
+          dynamicBeans.byPlugin(plugin).get(beanClass.getCanonicalName());
       if (provider != null) {
         beansByPlugin.put(plugin, getDynamicBean(bean, provider.get()));
       }
diff --git a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
index ad39633..f00e509 100644
--- a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -54,7 +54,6 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.StarredChangesUtil.IllegalLabelException;
-import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeMessageResource;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.SetPrivateOp;
@@ -68,6 +67,7 @@
 import com.google.gerrit.server.restapi.change.DeleteChange;
 import com.google.gerrit.server.restapi.change.DeletePrivate;
 import com.google.gerrit.server.restapi.change.GetAssignee;
+import com.google.gerrit.server.restapi.change.GetChange;
 import com.google.gerrit.server.restapi.change.GetHashtags;
 import com.google.gerrit.server.restapi.change.GetPastAssignees;
 import com.google.gerrit.server.restapi.change.GetPureRevert;
@@ -132,7 +132,7 @@
   private final PutTopic putTopic;
   private final ChangeIncludedIn includedIn;
   private final PostReviewers postReviewers;
-  private final ChangeJson.Factory changeJson;
+  private final Provider<GetChange> getChangeProvider;
   private final PostHashtags postHashtags;
   private final GetHashtags getHashtags;
   private final PutAssignee putAssignee;
@@ -180,7 +180,7 @@
       PutTopic putTopic,
       ChangeIncludedIn includedIn,
       PostReviewers postReviewers,
-      ChangeJson.Factory changeJson,
+      Provider<GetChange> getChangeProvider,
       PostHashtags postHashtags,
       GetHashtags getHashtags,
       PutAssignee putAssignee,
@@ -226,7 +226,7 @@
     this.putTopic = putTopic;
     this.includedIn = includedIn;
     this.postReviewers = postReviewers;
-    this.changeJson = changeJson;
+    this.getChangeProvider = getChangeProvider;
     this.postHashtags = postHashtags;
     this.getHashtags = getHashtags;
     this.putAssignee = putAssignee;
@@ -454,7 +454,9 @@
   @Override
   public ChangeInfo get(EnumSet<ListChangesOption> s) throws RestApiException {
     try {
-      return changeJson.create(s).format(change);
+      GetChange getChange = getChangeProvider.get();
+      s.forEach(getChange::addOption);
+      return getChange.apply(change).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot retrieve change", e);
     }
diff --git a/java/com/google/gerrit/server/change/PluginDefinedAttributesFactories.java b/java/com/google/gerrit/server/change/PluginDefinedAttributesFactories.java
new file mode 100644
index 0000000..9928125
--- /dev/null
+++ b/java/com/google/gerrit/server/change/PluginDefinedAttributesFactories.java
@@ -0,0 +1,64 @@
+// 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.server.change;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static java.util.concurrent.TimeUnit.MINUTES;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.common.PluginDefinedInfo;
+import com.google.gerrit.extensions.registration.Extension;
+import com.google.gerrit.server.DynamicOptions.BeanProvider;
+import com.google.gerrit.server.query.change.ChangeData;
+import java.util.Objects;
+import java.util.stream.Stream;
+
+/** Static helpers for use by {@link PluginDefinedAttributesFactory} implementations. */
+public class PluginDefinedAttributesFactories {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  @Nullable
+  public static ImmutableList<PluginDefinedInfo> createAll(
+      ChangeData cd,
+      BeanProvider beanProvider,
+      Stream<Extension<ChangeAttributeFactory>> attrFactories) {
+    ImmutableList<PluginDefinedInfo> result =
+        attrFactories
+            .map(e -> tryCreate(cd, beanProvider, e.getPluginName(), e.get()))
+            .filter(Objects::nonNull)
+            .collect(toImmutableList());
+    return !result.isEmpty() ? result : null;
+  }
+
+  @Nullable
+  private static PluginDefinedInfo tryCreate(
+      ChangeData cd, BeanProvider beanProvider, String plugin, ChangeAttributeFactory attrFactory) {
+    PluginDefinedInfo pdi = null;
+    try {
+      pdi = attrFactory.create(cd, beanProvider, plugin);
+    } catch (RuntimeException ex) {
+      logger.atWarning().atMostEvery(1, MINUTES).withCause(ex).log(
+          "error populating attribute on change %s from plugin %s", cd.getId(), plugin);
+    }
+    if (pdi != null) {
+      pdi.name = plugin;
+    }
+    return pdi;
+  }
+
+  private PluginDefinedAttributesFactories() {}
+}
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java b/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
index 066b443..7a7f71d 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
@@ -16,12 +16,11 @@
 
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.query.change.ChangeQueryBuilder.FIELD_LIMIT;
-import static java.util.concurrent.TimeUnit.MINUTES;
 
 import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.common.PluginDefinedInfo;
 import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.Extension;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.query.IndexPredicate;
@@ -34,6 +33,7 @@
 import com.google.gerrit.server.DynamicOptions.DynamicBean;
 import com.google.gerrit.server.account.AccountLimits;
 import com.google.gerrit.server.change.ChangeAttributeFactory;
+import com.google.gerrit.server.change.PluginDefinedAttributesFactories;
 import com.google.gerrit.server.change.PluginDefinedAttributesFactory;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.ChangeIndexRewriter;
@@ -44,7 +44,6 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -60,8 +59,6 @@
     implements DynamicOptions.BeanReceiver,
         DynamicOptions.BeanProvider,
         PluginDefinedAttributesFactory {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   private final Provider<CurrentUser> userProvider;
   private final ChangeNotes.Factory notesFactory;
   private final ImmutableListMultimap<String, ChangeAttributeFactory> attributeFactoriesByPlugin;
@@ -136,26 +133,11 @@
 
   @Override
   public List<PluginDefinedInfo> create(ChangeData cd) {
-    List<PluginDefinedInfo> plugins = new ArrayList<>(attributeFactoriesByPlugin.size());
-    for (Map.Entry<String, ChangeAttributeFactory> e : attributeFactoriesByPlugin.entries()) {
-      String plugin = e.getKey();
-      PluginDefinedInfo pda = null;
-      try {
-        pda = e.getValue().create(cd, this, plugin);
-      } catch (RuntimeException ex) {
-        // Log once a minute, to avoid spamming logs with one stack trace per change.
-        logger.atWarning().atMostEvery(1, MINUTES).withCause(ex).log(
-            "error populating attribute on change %s from plugin %s", cd.getId(), plugin);
-      }
-      if (pda != null) {
-        pda.name = plugin;
-        plugins.add(pda);
-      }
-    }
-    if (plugins.isEmpty()) {
-      plugins = null;
-    }
-    return plugins;
+    return PluginDefinedAttributesFactories.createAll(
+        cd,
+        this,
+        attributeFactoriesByPlugin.entries().stream()
+            .map(e -> new Extension<>(e.getKey(), e::getValue)));
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/restapi/change/GetChange.java b/java/com/google/gerrit/server/restapi/change/GetChange.java
index 2decb4e..461a669 100644
--- a/java/com/google/gerrit/server/restapi/change/GetChange.java
+++ b/java/com/google/gerrit/server/restapi/change/GetChange.java
@@ -14,25 +14,41 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Streams;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.ListOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.PluginDefinedInfo;
+import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.DynamicOptions;
+import com.google.gerrit.server.DynamicOptions.DynamicBean;
+import com.google.gerrit.server.change.ChangeAttributeFactory;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.PluginDefinedAttributesFactories;
 import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.Map;
 import org.kohsuke.args4j.Option;
 
-public class GetChange implements RestReadView<ChangeResource> {
+public class GetChange
+    implements RestReadView<ChangeResource>,
+        DynamicOptions.BeanReceiver,
+        DynamicOptions.BeanProvider {
   private final ChangeJson.Factory json;
+  private final DynamicMap<ChangeAttributeFactory> attrFactories;
   private final EnumSet<ListChangesOption> options = EnumSet.noneOf(ListChangesOption.class);
+  private final Map<String, DynamicBean> dynamicBeans = new HashMap<>();
 
   @Option(name = "-o", usage = "Output options")
-  void addOption(ListChangesOption o) {
+  public void addOption(ListChangesOption o) {
     options.add(o);
   }
 
@@ -42,16 +58,35 @@
   }
 
   @Inject
-  GetChange(ChangeJson.Factory json) {
+  GetChange(ChangeJson.Factory json, DynamicMap<ChangeAttributeFactory> attrFactories) {
     this.json = json;
+    this.attrFactories = attrFactories;
+  }
+
+  @Override
+  public void setDynamicBean(String plugin, DynamicOptions.DynamicBean dynamicBean) {
+    dynamicBeans.put(plugin, dynamicBean);
+  }
+
+  @Override
+  public DynamicBean getDynamicBean(String plugin) {
+    return dynamicBeans.get(plugin);
   }
 
   @Override
   public Response<ChangeInfo> apply(ChangeResource rsrc) throws OrmException {
-    return Response.withMustRevalidate(json.create(options).format(rsrc));
+    return Response.withMustRevalidate(newChangeJson().format(rsrc));
   }
 
   Response<ChangeInfo> apply(RevisionResource rsrc) throws OrmException {
-    return Response.withMustRevalidate(json.create(options).format(rsrc));
+    return Response.withMustRevalidate(newChangeJson().format(rsrc));
+  }
+
+  private ChangeJson newChangeJson() {
+    return json.create(options, this::buildPluginInfo);
+  }
+
+  private ImmutableList<PluginDefinedInfo> buildPluginInfo(ChangeData cd) {
+    return PluginDefinedAttributesFactories.createAll(cd, this, Streams.stream(attrFactories));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/GetDetail.java b/java/com/google/gerrit/server/restapi/change/GetDetail.java
index ab75ab7..06fdd99 100644
--- a/java/com/google/gerrit/server/restapi/change/GetDetail.java
+++ b/java/com/google/gerrit/server/restapi/change/GetDetail.java
@@ -18,12 +18,14 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.DynamicOptions;
+import com.google.gerrit.server.DynamicOptions.DynamicBean;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import org.kohsuke.args4j.Option;
 
-public class GetDetail implements RestReadView<ChangeResource> {
+public class GetDetail implements RestReadView<ChangeResource>, DynamicOptions.BeanReceiver {
   private final GetChange delegate;
 
   @Option(name = "-o", usage = "Output options")
@@ -47,6 +49,16 @@
   }
 
   @Override
+  public void setDynamicBean(String plugin, DynamicBean dynamicBean) {
+    delegate.setDynamicBean(plugin, dynamicBean);
+  }
+
+  @Override
+  public Class<? extends DynamicOptions.BeanReceiver> getExportedBeanReceiver() {
+    return delegate.getExportedBeanReceiver();
+  }
+
+  @Override
   public Response<ChangeInfo> apply(ChangeResource rsrc) throws OrmException {
     return delegate.apply(rsrc);
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PluginFieldsIT.java b/javatests/com/google/gerrit/acceptance/api/change/PluginFieldsIT.java
index 88ef004..b8ec5d92 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/PluginFieldsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/PluginFieldsIT.java
@@ -36,6 +36,7 @@
 import com.google.gerrit.server.DynamicOptions.DynamicBean;
 import com.google.gerrit.server.change.ChangeAttributeFactory;
 import com.google.gerrit.server.query.change.OutputStreamQuery;
+import com.google.gerrit.server.restapi.change.GetChange;
 import com.google.gerrit.server.restapi.change.QueryChanges;
 import com.google.gerrit.sshd.PluginCommandModule;
 import com.google.gerrit.sshd.commands.Query;
@@ -53,6 +54,9 @@
 
 @UseSsh
 public class PluginFieldsIT extends AbstractDaemonTest {
+  private static final Gson REST_GSON = OutputFormat.JSON.newGson();
+  private static final Gson SSH_GSON = OutputStreamQuery.GSON;
+
   static class MyInfo extends PluginDefinedInfo {
     @Nullable String theAttribute;
 
@@ -135,28 +139,50 @@
     @Override
     protected void configureServlets() {
       bind(DynamicBean.class).annotatedWith(Exports.named(QueryChanges.class)).to(MyOptions.class);
+      bind(DynamicBean.class).annotatedWith(Exports.named(GetChange.class)).to(MyOptions.class);
     }
   }
 
   @Test
   public void queryChangeApiWithNullAttribute() throws Exception {
-    queryChangeWithNullAttribute(
+    getChangeWithNullAttribute(
         id -> pluginInfoFromSingletonList(gApi.changes().query(id.toString()).get()));
   }
 
   @Test
   public void queryChangeRestWithNullAttribute() throws Exception {
-    queryChangeWithNullAttribute(
+    getChangeWithNullAttribute(
         id -> pluginInfoFromSingletonListRest(adminRestSession.get(changeQueryUrl(id))));
   }
 
   @Test
   public void queryChangeSshWithNullAttribute() throws Exception {
-    queryChangeWithNullAttribute(
+    getChangeWithNullAttribute(
         id -> pluginInfoFromSingletonListSsh(adminSshSession.exec(changeQueryCmd(id))));
   }
 
-  private void queryChangeWithNullAttribute(PluginInfoGetter getter) throws Exception {
+  @Test
+  public void getChangeApiWithNullAttribute() throws Exception {
+    getChangeWithNullAttribute(
+        id -> pluginInfoFromChangeInfo(gApi.changes().id(id.toString()).get()));
+  }
+
+  @Test
+  public void getChangeRestWithNullAttribute() throws Exception {
+    getChangeWithNullAttribute(
+        id -> pluginInfoFromChangeInfoRest(adminRestSession.get(changeUrl(id))));
+  }
+
+  @Test
+  public void getChangeDetailRestWithNullAttribute() throws Exception {
+    getChangeWithNullAttribute(
+        id -> pluginInfoFromChangeInfoRest(adminRestSession.get(changeDetailUrl(id))));
+  }
+
+  // No tests for /detail via the extension API, since the extension API doesn't have that method.
+  // No tests for getting a single change over SSH, since the only API is the query API.
+
+  private void getChangeWithNullAttribute(PluginInfoGetter getter) throws Exception {
     Change.Id id = createChange().getChange().getId();
     assertThat(getter.call(id)).isNull();
 
@@ -169,29 +195,48 @@
 
   @Test
   public void queryChangeApiWithSimpleAttribute() throws Exception {
-    queryChangeWithSimpleAttribute(
+    getChangeWithSimpleAttribute(
         id -> pluginInfoFromSingletonList(gApi.changes().query(id.toString()).get()));
   }
 
   @Test
   public void queryChangeRestWithSimpleAttribute() throws Exception {
-    queryChangeWithSimpleAttribute(
+    getChangeWithSimpleAttribute(
         id -> pluginInfoFromSingletonListRest(adminRestSession.get(changeQueryUrl(id))));
   }
 
   @Test
   public void queryChangeSshWithSimpleAttribute() throws Exception {
-    queryChangeWithSimpleAttribute(
+    getChangeWithSimpleAttribute(
         id -> pluginInfoFromSingletonListSsh(adminSshSession.exec(changeQueryCmd(id))));
   }
 
-  private void queryChangeWithSimpleAttribute(PluginInfoGetter getter) throws Exception {
+  @Test
+  public void getChangeApiWithSimpleAttribute() throws Exception {
+    getChangeWithSimpleAttribute(
+        id -> pluginInfoFromChangeInfo(gApi.changes().id(id.toString()).get()));
+  }
+
+  @Test
+  public void getChangeRestWithSimpleAttribute() throws Exception {
+    getChangeWithSimpleAttribute(
+        id -> pluginInfoFromChangeInfoRest(adminRestSession.get(changeUrl(id))));
+  }
+
+  @Test
+  public void getChangeDetailRestWithSimpleAttribute() throws Exception {
+    getChangeWithSimpleAttribute(
+        id -> pluginInfoFromChangeInfoRest(adminRestSession.get(changeDetailUrl(id))));
+  }
+
+  // No tests for getting a single change over SSH, since the only API is the query API.
+
+  private void getChangeWithSimpleAttribute(PluginInfoGetter getter) throws Exception {
     Change.Id id = createChange().getChange().getId();
     assertThat(getter.call(id)).isNull();
 
     try (AutoCloseable ignored = installPlugin("my-plugin", SimpleAttributeModule.class)) {
-      List<MyInfo> infos = getter.call(id);
-      assertThat(infos).containsExactly(new MyInfo("my-plugin", "change " + id));
+      assertThat(getter.call(id)).containsExactly(new MyInfo("my-plugin", "change " + id));
     }
 
     assertThat(getter.call(id)).isNull();
@@ -199,7 +244,7 @@
 
   @Test
   public void queryChangeSshWithOption() throws Exception {
-    queryChangeWithOption(
+    getChangeWithOption(
         id -> pluginInfoFromSingletonListSsh(adminSshSession.exec(changeQueryCmd(id))),
         (id, opts) ->
             pluginInfoFromSingletonListSsh(adminSshSession.exec(changeQueryCmd(id, opts))));
@@ -207,19 +252,37 @@
 
   @Test
   public void queryChangeRestWithOption() throws Exception {
-    queryChangeWithOption(
+    getChangeWithOption(
         id -> pluginInfoFromSingletonListRest(adminRestSession.get(changeQueryUrl(id))),
         (id, opts) ->
             pluginInfoFromSingletonListRest(adminRestSession.get(changeQueryUrl(id, opts))));
   }
 
+  @Test
+  public void getChangeRestWithOption() throws Exception {
+    getChangeWithOption(
+        id -> pluginInfoFromChangeInfoRest(adminRestSession.get(changeUrl(id))),
+        (id, opts) -> pluginInfoFromChangeInfoRest(adminRestSession.get(changeUrl(id, opts))));
+  }
+
+  @Test
+  public void getChangeDetailRestWithOption() throws Exception {
+    getChangeWithOption(
+        id -> pluginInfoFromChangeInfoRest(adminRestSession.get(changeDetailUrl(id))),
+        (id, opts) ->
+            pluginInfoFromChangeInfoRest(adminRestSession.get(changeDetailUrl(id, opts))));
+  }
+
+  // No tests for /detail via the extension API, since the extension API doesn't have that method.
+  // No tests for getting a single change over SSH, since the only API is the query API.
+
   // No test for plugin-provided options over the extension API. There are currently two separate
   // DynamicMap<DynamicBean> maps initialized in the SSH and HTTP injectors, and plugins have to
   // define separate SSH/HTTP modules and bind their DynamicBeans in each one. To use the extension
   // API, we would have to move everything into the sys injector.
   // TODO(dborowitz): Determine whether this is possible without breaking existing plugins.
 
-  private void queryChangeWithOption(
+  private void getChangeWithOption(
       PluginInfoGetter getterWithoutOptions, PluginInfoGetterWithOptions getterWithOptions)
       throws Exception {
     Change.Id id = createChange().getChange().getId();
@@ -246,7 +309,7 @@
 
   private String changeQueryUrl(Change.Id id, ImmutableListMultimap<String, String> opts) {
     String url = "/changes/?q=" + id;
-    String queryString = Joiner.on('&').withKeyValueSeparator('=').join(opts.entries());
+    String queryString = buildQueryString(opts);
     if (!queryString.isEmpty()) {
       url += "&" + queryString;
     }
@@ -266,9 +329,44 @@
         + id;
   }
 
+  private String changeUrl(Change.Id id) {
+    return changeUrl(id, ImmutableListMultimap.of());
+  }
+
+  private String changeUrl(Change.Id id, ImmutableListMultimap<String, String> pluginOptions) {
+    return changeUrl(id, "", pluginOptions);
+  }
+
+  private String changeDetailUrl(Change.Id id) {
+    return changeDetailUrl(id, ImmutableListMultimap.of());
+  }
+
+  private String changeDetailUrl(
+      Change.Id id, ImmutableListMultimap<String, String> pluginOptions) {
+    return changeUrl(id, "/detail", pluginOptions);
+  }
+
+  private String changeUrl(
+      Change.Id id, String suffix, ImmutableListMultimap<String, String> pluginOptions) {
+    String url = "/changes/" + project + "~" + id + suffix;
+    String queryString = buildQueryString(pluginOptions);
+    if (!queryString.isEmpty()) {
+      url += "?" + queryString;
+    }
+    return url;
+  }
+
+  private static String buildQueryString(ImmutableListMultimap<String, String> opts) {
+    return Joiner.on('&').withKeyValueSeparator('=').join(opts.entries());
+  }
+
   private static List<MyInfo> pluginInfoFromSingletonList(List<ChangeInfo> changeInfos) {
     assertThat(changeInfos).hasSize(1);
-    List<PluginDefinedInfo> pluginInfo = changeInfos.get(0).plugins;
+    return pluginInfoFromChangeInfo(changeInfos.get(0));
+  }
+
+  private static List<MyInfo> pluginInfoFromChangeInfo(ChangeInfo changeInfo) {
+    List<PluginDefinedInfo> pluginInfo = changeInfo.plugins;
     if (pluginInfo == null) {
       return null;
     }
@@ -281,15 +379,11 @@
 
     // Don't deserialize to ChangeInfo directly, since that would treat the plugins field as
     // List<PluginDefinedInfo> and ignore the unknown keys found in MyInfo.
-    Gson gson = OutputFormat.JSON.newGson();
     List<Map<String, Object>> changeInfos =
-        gson.fromJson(res.getReader(), new TypeToken<List<Map<String, Object>>>() {}.getType());
+        REST_GSON.fromJson(
+            res.getReader(), new TypeToken<List<Map<String, Object>>>() {}.getType());
     assertThat(changeInfos).hasSize(1);
-    Object plugins = changeInfos.get(0).get("plugins");
-    if (plugins == null) {
-      return null;
-    }
-    return gson.fromJson(gson.toJson(plugins), new TypeToken<List<MyInfo>>() {}.getType());
+    return myInfo(changeInfos.get(0));
   }
 
   @Nullable
@@ -315,6 +409,25 @@
     return gson.fromJson(gson.toJson(plugins), new TypeToken<List<MyInfo>>() {}.getType());
   }
 
+  @Nullable
+  private List<MyInfo> pluginInfoFromChangeInfoRest(RestResponse res) throws Exception {
+    res.assertOK();
+
+    // Don't deserialize to ChangeInfo directly, since that would treat the plugins field as
+    // List<PluginDefinedInfo> and ignore the unknown keys found in MyInfo.
+    return myInfo(
+        REST_GSON.fromJson(res.getReader(), new TypeToken<Map<String, Object>>() {}.getType()));
+  }
+
+  private static List<MyInfo> myInfo(Map<String, Object> changeInfo) {
+    Object plugins = changeInfo.get("plugins");
+    if (plugins == null) {
+      return null;
+    }
+    return REST_GSON.fromJson(
+        REST_GSON.toJson(plugins), new TypeToken<List<MyInfo>>() {}.getType());
+  }
+
   @FunctionalInterface
   private interface PluginInfoGetter {
     List<MyInfo> call(Change.Id id) throws Exception;