Add a config option to deprecate change identifiers one-by-one

This commit adds a config option to deprecate change identifiers on a
one-by-one basis. This allows host admins to gradually turn off
deprecated identifiers.

This change only affects the API endpoints. Internals can still use old
identifiers to get changes.

Change-Id: I97a7ad4b82cb48be31af996f391d0dc9388fa406
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 45bc045..91a837d 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -1133,6 +1133,18 @@
 +
 Default is true.
 
+[[change.api.allowedIdentifier]]change.api.allowedIdentifier::
++
+Change identifier(s) that are allowed on the API. See
+link:rest-api-changes.html#change-id[Change Id] for more information.
++
+Possible values are `ALL`, `TRIPLET`, `NUMERIC_ID`, `I_HASH`, and
+`COMMIT_HASH` or any combination of those as a string list.
+`PROJECT_NUMERIC_ID` is always allowed and doesn't need to be listed
+explicitly.
++
+Default is `ALL`.
+
 [[change.cacheAutomerge]]change.cacheAutomerge::
 +
 When reviewing diff commits, the left-hand side shows the output of the
diff --git a/java/com/google/gerrit/extensions/restapi/DeprecatedIdentifierException.java b/java/com/google/gerrit/extensions/restapi/DeprecatedIdentifierException.java
new file mode 100644
index 0000000..aa28cfc
--- /dev/null
+++ b/java/com/google/gerrit/extensions/restapi/DeprecatedIdentifierException.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2017 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.extensions.restapi;
+
+/** Named resource was accessed using a deprecated identifier. */
+public class DeprecatedIdentifierException extends BadRequestException {
+  private static final long serialVersionUID = 1L;
+
+  /** Requested resource using a deprecated identifier. */
+  public DeprecatedIdentifierException(String msg) {
+    super(msg);
+  }
+}
diff --git a/java/com/google/gerrit/server/ChangeFinder.java b/java/com/google/gerrit/server/ChangeFinder.java
index 4b7cbac..cb82778 100644
--- a/java/com/google/gerrit/server/ChangeFinder.java
+++ b/java/com/google/gerrit/server/ChangeFinder.java
@@ -17,8 +17,10 @@
 import com.google.common.base.Throwables;
 import com.google.common.cache.Cache;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.common.primitives.Ints;
+import com.google.gerrit.extensions.restapi.DeprecatedIdentifierException;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.metrics.Counter1;
 import com.google.gerrit.metrics.Description;
@@ -30,6 +32,8 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.change.ChangeTriplet;
+import com.google.gerrit.server.config.ConfigUtil;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -46,6 +50,7 @@
 import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Config;
 
 @Singleton
 public class ChangeFinder {
@@ -60,10 +65,11 @@
     };
   }
 
-  private enum ChangeIdType {
+  public enum ChangeIdType {
+    ALL,
     TRIPLET,
     NUMERIC_ID,
-    CHANGE_ID,
+    I_HASH,
     PROJECT_NUMERIC_ID,
     COMMIT_HASH
   }
@@ -74,6 +80,7 @@
   private final Provider<ReviewDb> reviewDb;
   private final ChangeNotes.Factory changeNotesFactory;
   private final Counter1<ChangeIdType> changeIdCounter;
+  private final ImmutableSet<ChangeIdType> allowedIdTypes;
 
   @Inject
   ChangeFinder(
@@ -82,7 +89,8 @@
       Provider<InternalChangeQuery> queryProvider,
       Provider<ReviewDb> reviewDb,
       ChangeNotes.Factory changeNotesFactory,
-      MetricMaker metricMaker) {
+      MetricMaker metricMaker,
+      @GerritServerConfig Config config) {
     this.indexConfig = indexConfig;
     this.changeIdProjectCache = changeIdProjectCache;
     this.queryProvider = queryProvider;
@@ -95,16 +103,41 @@
                 .setRate()
                 .setUnit("requests"),
             Field.ofEnum(ChangeIdType.class, "change_id_type"));
+    List<ChangeIdType> configuredChangeIdTypes =
+        ConfigUtil.getEnumList(config, "change", "api", "allowedIdentifier", ChangeIdType.ALL);
+    // Ensure that PROJECT_NUMERIC_ID can't be removed
+    configuredChangeIdTypes.add(ChangeIdType.PROJECT_NUMERIC_ID);
+    this.allowedIdTypes = ImmutableSet.copyOf(configuredChangeIdTypes);
   }
 
   /**
    * Find changes matching the given identifier.
    *
-   * @param id change identifier, either a numeric ID, a Change-Id, or project~branch~id triplet.
+   * @param id change identifier.
    * @return possibly-empty list of notes for all matching changes; may or may not be visible.
    * @throws OrmException if an error occurred querying the database.
    */
   public List<ChangeNotes> find(String id) throws OrmException {
+    try {
+      return find(id, false);
+    } catch (DeprecatedIdentifierException e) {
+      // This can't happen because we don't enforce deprecation
+      throw new OrmException(e);
+    }
+  }
+
+  /**
+   * Find changes matching the given identifier.
+   *
+   * @param id change identifier.
+   * @param enforceDeprecation boolean to see if we should throw {@link
+   *     DeprecatedIdentifierException} in case the identifier is deprecated
+   * @return possibly-empty list of notes for all matching changes; may or may not be visible.
+   * @throws OrmException if an error occurred querying the database
+   * @throws DeprecatedIdentifierException if the identifier is deprecated.
+   */
+  public List<ChangeNotes> find(String id, boolean enforceDeprecation)
+      throws OrmException, DeprecatedIdentifierException {
     if (id.isEmpty()) {
       return Collections.emptyList();
     }
@@ -115,7 +148,7 @@
       // Try project~numericChangeId
       Integer n = Ints.tryParse(id.substring(z + 1));
       if (n != null) {
-        changeIdCounter.increment(ChangeIdType.PROJECT_NUMERIC_ID);
+        checkIdType(ChangeIdType.PROJECT_NUMERIC_ID, enforceDeprecation, n.toString());
         return fromProjectNumber(id.substring(0, z), n.intValue());
       }
     }
@@ -124,7 +157,7 @@
       // Try numeric changeId
       Integer n = Ints.tryParse(id);
       if (n != null) {
-        changeIdCounter.increment(ChangeIdType.NUMERIC_ID);
+        checkIdType(ChangeIdType.NUMERIC_ID, enforceDeprecation, n.toString());
         return find(new Change.Id(n));
       }
     }
@@ -135,7 +168,7 @@
 
     // Try commit hash
     if (id.matches("^([0-9a-fA-F]{" + RevId.ABBREV_LEN + "," + RevId.LEN + "})$")) {
-      changeIdCounter.increment(ChangeIdType.COMMIT_HASH);
+      checkIdType(ChangeIdType.COMMIT_HASH, enforceDeprecation, id);
       return asChangeNotes(query.byCommit(id));
     }
 
@@ -144,7 +177,7 @@
       Optional<ChangeTriplet> triplet = ChangeTriplet.parse(id, y, z);
       if (triplet.isPresent()) {
         ChangeTriplet t = triplet.get();
-        changeIdCounter.increment(ChangeIdType.TRIPLET);
+        checkIdType(ChangeIdType.TRIPLET, enforceDeprecation, triplet.get().toString());
         return asChangeNotes(query.byBranchKey(t.branch(), t.id()));
       }
     }
@@ -152,7 +185,7 @@
     // Try isolated Ihash... format ("Change-Id: Ihash").
     List<ChangeNotes> notes = asChangeNotes(query.byKeyPrefix(id));
     if (!notes.isEmpty()) {
-      changeIdCounter.increment(ChangeIdType.CHANGE_ID);
+      checkIdType(ChangeIdType.I_HASH, enforceDeprecation, id);
     }
     return notes;
   }
@@ -222,4 +255,18 @@
     }
     return notes;
   }
+
+  private void checkIdType(ChangeIdType type, boolean enforceDeprecation, String val)
+      throws DeprecatedIdentifierException {
+    if (enforceDeprecation
+        && !allowedIdTypes.contains(ChangeIdType.ALL)
+        && !allowedIdTypes.contains(type)) {
+      throw new DeprecatedIdentifierException(
+          String.format(
+              "The provided change identifier %s is deprecated. "
+                  + "Use 'project~changeNumber' instead.",
+              val));
+    }
+    changeIdCounter.increment(type);
+  }
 }
diff --git a/java/com/google/gerrit/server/account/StarredChanges.java b/java/com/google/gerrit/server/account/StarredChanges.java
index 38c95e6..6dfd132 100644
--- a/java/com/google/gerrit/server/account/StarredChanges.java
+++ b/java/com/google/gerrit/server/account/StarredChanges.java
@@ -72,7 +72,7 @@
 
   @Override
   public AccountResource.StarredChange parse(AccountResource parent, IdString id)
-      throws ResourceNotFoundException, OrmException, PermissionBackendException {
+      throws RestApiException, OrmException, PermissionBackendException {
     IdentifiedUser user = parent.getUser();
     ChangeResource change = changes.parse(TopLevelResource.INSTANCE, id);
     if (starredChangesUtil
@@ -103,7 +103,7 @@
 
   @Override
   public RestModifyView<AccountResource, EmptyInput> create(AccountResource parent, IdString id)
-      throws UnprocessableEntityException {
+      throws RestApiException {
     try {
       return createProvider.get().setChange(changes.parse(TopLevelResource.INSTANCE, id));
     } catch (ResourceNotFoundException e) {
diff --git a/java/com/google/gerrit/server/account/Stars.java b/java/com/google/gerrit/server/account/Stars.java
index 9019ad7..5eb8d7b 100644
--- a/java/com/google/gerrit/server/account/Stars.java
+++ b/java/com/google/gerrit/server/account/Stars.java
@@ -21,7 +21,7 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.RestView;
@@ -66,7 +66,7 @@
 
   @Override
   public Star parse(AccountResource parent, IdString id)
-      throws ResourceNotFoundException, OrmException, PermissionBackendException {
+      throws RestApiException, OrmException, PermissionBackendException {
     IdentifiedUser user = parent.getUser();
     ChangeResource change = changes.parse(TopLevelResource.INSTANCE, id);
     Set<String> labels = starredChangesUtil.getLabels(user.getAccountId(), change.getId());
diff --git a/java/com/google/gerrit/server/change/ChangesCollection.java b/java/com/google/gerrit/server/change/ChangesCollection.java
index a0b6c96..6ce661e 100644
--- a/java/com/google/gerrit/server/change/ChangesCollection.java
+++ b/java/com/google/gerrit/server/change/ChangesCollection.java
@@ -81,8 +81,8 @@
 
   @Override
   public ChangeResource parse(TopLevelResource root, IdString id)
-      throws ResourceNotFoundException, OrmException, PermissionBackendException {
-    List<ChangeNotes> notes = changeFinder.find(id.encoded());
+      throws RestApiException, OrmException, PermissionBackendException {
+    List<ChangeNotes> notes = changeFinder.find(id.encoded(), true);
     if (notes.isEmpty()) {
       throw new ResourceNotFoundException(id);
     } else if (notes.size() != 1) {
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIdIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIdIT.java
index e0fc358..0b7f340 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIdIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIdIT.java
@@ -17,10 +17,12 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.extensions.restapi.DeprecatedIdentifierException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.reviewdb.client.Project;
 import org.junit.Before;
@@ -119,4 +121,27 @@
     exception.expect(ResourceNotFoundException.class);
     gApi.changes().id("I1234567890");
   }
+
+  @Test
+  @GerritConfig(
+    name = "change.api.allowedIdentifier",
+    values = {"PROJECT_NUMERIC_ID", "NUMERIC_ID"}
+  )
+  public void deprecatedChangeIdReturnsBadRequest() throws Exception {
+    // project~changeNumber still works
+    ChangeApi cApi1 = gApi.changes().id(project.get(), changeInfo._number);
+    assertThat(cApi1.get().changeId).isEqualTo(changeInfo.changeId);
+    // Change number still works
+    ChangeApi cApi2 = gApi.changes().id(changeInfo._number);
+    assertThat(cApi2.get().changeId).isEqualTo(changeInfo.changeId);
+    // IHash throws
+    ChangeInfo ci =
+        gApi.changes().create(new ChangeInput(project.get(), "master", "different message")).get();
+    exception.expect(DeprecatedIdentifierException.class);
+    exception.expectMessage(
+        "The provided change identifier "
+            + ci.changeId
+            + " is deprecated. Use 'project~changeNumber' instead.");
+    gApi.changes().id(ci.changeId);
+  }
 }