GetOwnedPath: Limit the number of returned paths

Computing all owned paths can be expensive for large changes.

Often callers do not need all owned paths, hence it should be possible
to limit the result. E.g. the frontend may use this REST endpoint to
highlight files on the change screen that are owned by the current user,
but for large changes the frontend is not showing all files by default,
hence retrieving all owned files in this case would be wasteful.

To avoid unintended high latency when using this REST endpoint we limit
the results in the response by default to 50 and if callers want to get
more results they need to set a higher limit explicitly. This behaviour
is consistent with the get code owners REST endpoints with also have a
default limit.

We also add a start parameter that allows to skip owned files. This can
be used to paginate over the owned files.

Signed-off-by: Edwin Kempin <ekempin@google.com>
Change-Id: I6340d16e9a95c57a338f2f025793642305244ed1
diff --git a/java/com/google/gerrit/plugins/codeowners/api/OwnedPathsInfo.java b/java/com/google/gerrit/plugins/codeowners/api/OwnedPathsInfo.java
index 0fc1497..ba41418 100644
--- a/java/com/google/gerrit/plugins/codeowners/api/OwnedPathsInfo.java
+++ b/java/com/google/gerrit/plugins/codeowners/api/OwnedPathsInfo.java
@@ -31,4 +31,11 @@
    * <p>The paths are sorted alphabetically.
    */
   public List<String> ownedPaths;
+
+  /**
+   * Whether the request would deliver more results if not limited.
+   *
+   * <p>Not set if {@code false}.
+   */
+  public Boolean more;
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/api/RevisionCodeOwners.java b/java/com/google/gerrit/plugins/codeowners/api/RevisionCodeOwners.java
index 1b90340..7b2655b 100644
--- a/java/com/google/gerrit/plugins/codeowners/api/RevisionCodeOwners.java
+++ b/java/com/google/gerrit/plugins/codeowners/api/RevisionCodeOwners.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 
 /**
  * Revision-level Java API of the code-owners plugin.
@@ -95,8 +96,40 @@
 
   /** Request to check code owner config files. */
   abstract class OwnedPathsRequest {
+    private Integer start;
+    private Integer limit;
     private String user;
 
+    /**
+     * Sets a limit on the number of owned paths that should be returned.
+     *
+     * @param start number of owned paths to skip
+     */
+    public OwnedPathsRequest withStart(int start) {
+      this.start = start;
+      return this;
+    }
+
+    /** Returns the number of owned paths to skip. */
+    public Optional<Integer> getStart() {
+      return Optional.ofNullable(start);
+    }
+
+    /**
+     * Sets a limit on the number of owned paths that should be returned.
+     *
+     * @param limit the limit
+     */
+    public OwnedPathsRequest withLimit(int limit) {
+      this.limit = limit;
+      return this;
+    }
+
+    /** Returns the limit. */
+    public Optional<Integer> getLimit() {
+      return Optional.ofNullable(limit);
+    }
+
     /** Sets the user for which the owned paths should be retrieved. */
     public OwnedPathsRequest forUser(String user) {
       this.user = user;
diff --git a/java/com/google/gerrit/plugins/codeowners/api/impl/RevisionCodeOwnersImpl.java b/java/com/google/gerrit/plugins/codeowners/api/impl/RevisionCodeOwnersImpl.java
index d42327c..db8cb27 100644
--- a/java/com/google/gerrit/plugins/codeowners/api/impl/RevisionCodeOwnersImpl.java
+++ b/java/com/google/gerrit/plugins/codeowners/api/impl/RevisionCodeOwnersImpl.java
@@ -75,6 +75,8 @@
       public OwnedPathsInfo get() throws RestApiException {
         try {
           GetOwnedPaths getOwnedPaths = getOwnedPathsProvider.get();
+          getStart().ifPresent(getOwnedPaths::setStart);
+          getLimit().ifPresent(getOwnedPaths::setLimit);
           getOwnedPaths.setUser(getUser());
           return getOwnedPaths.apply(revisionResource).value();
         } catch (Exception e) {
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheck.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheck.java
index af7d9b7..b17651e 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheck.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheck.java
@@ -121,21 +121,23 @@
    * @param changeNotes the change notes for which the owned files should be returned
    * @param patchSet the patch set for which the owned files should be returned
    * @param accountId account ID of the code owner for which the owned files should be returned
+   * @param start number of owned paths to skip
    * @param limit the max number of owned paths that should be returned (0 = unlimited)
    * @return the paths of the files in the given patch set that are owned by the specified account
    * @throws ResourceConflictException if the destination branch of the change no longer exists
    */
   public ImmutableList<Path> getOwnedPaths(
-      ChangeNotes changeNotes, PatchSet patchSet, Account.Id accountId, int limit)
+      ChangeNotes changeNotes, PatchSet patchSet, Account.Id accountId, int start, int limit)
       throws ResourceConflictException {
     try (Timer0.Context ctx = codeOwnerMetrics.computeOwnedPaths.start()) {
       logger.atFine().log(
           "compute owned paths for account %d (project = %s, change = %d, patch set = %d,"
-              + " limit = %d)",
+              + " start = %d, limit = %d)",
           accountId.get(),
           changeNotes.getProjectName(),
           changeNotes.getChangeId().get(),
           patchSet.id().get(),
+          start,
           limit);
       Stream<Path> ownedPaths =
           getFileStatusesForAccount(changeNotes, patchSet, accountId)
@@ -149,6 +151,9 @@
               .filter(
                   pathCodeOwnerStatus -> pathCodeOwnerStatus.status() == CodeOwnerStatus.APPROVED)
               .map(PathCodeOwnerStatus::path);
+      if (start > 0) {
+        ownedPaths = ownedPaths.skip(start);
+      }
       if (limit > 0) {
         ownedPaths = ownedPaths.limit(limit);
       }
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersOnAddReviewer.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersOnAddReviewer.java
index e6a55ee..403fe49 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersOnAddReviewer.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersOnAddReviewer.java
@@ -164,7 +164,11 @@
         // limit + 1, so that we can show an indicator if there are more than <limit> files.
         ownedPaths =
             codeOwnerApprovalCheck.getOwnedPaths(
-                changeNotes, changeNotes.getCurrentPatchSet(), reviewerAccountId, limit + 1);
+                changeNotes,
+                changeNotes.getCurrentPatchSet(),
+                reviewerAccountId,
+                /* start= */ 0,
+                limit + 1);
       } catch (RestApiException e) {
         logger.atFine().withCause(e).log(
             "Couldn't compute owned paths of change %s for account %s",
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/OnCodeOwnerApproval.java b/java/com/google/gerrit/plugins/codeowners/backend/OnCodeOwnerApproval.java
index e9e2c39..9803951 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/OnCodeOwnerApproval.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/OnCodeOwnerApproval.java
@@ -124,7 +124,11 @@
       // limit + 1, so that we can show an indicator if there are more than <limit> files.
       ownedPaths =
           codeOwnerApprovalCheck.getOwnedPaths(
-              changeNotes, changeNotes.getCurrentPatchSet(), user.getAccountId(), limit + 1);
+              changeNotes,
+              changeNotes.getCurrentPatchSet(),
+              user.getAccountId(),
+              /* start= */ 0,
+              limit + 1);
     } catch (RestApiException e) {
       logger.atFine().withCause(e).log(
           "Couldn't compute owned paths of change %s for account %s",
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/GetOwnedPaths.java b/java/com/google/gerrit/plugins/codeowners/restapi/GetOwnedPaths.java
index 0430f94..1f2298b 100644
--- a/java/com/google/gerrit/plugins/codeowners/restapi/GetOwnedPaths.java
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/GetOwnedPaths.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.Account;
@@ -41,11 +42,33 @@
  * /changes/<change-id>/revisions/<revision-id>/owned_paths} requests.
  */
 public class GetOwnedPaths implements RestReadView<RevisionResource> {
+  @VisibleForTesting public static final int DEFAULT_LIMIT = 50;
+
   private final AccountResolver accountResolver;
   private final CodeOwnerApprovalCheck codeOwnerApprovalCheck;
 
+  private int start;
+  private int limit = DEFAULT_LIMIT;
   private String user;
 
+  @Option(
+      name = "--limit",
+      aliases = {"-n"},
+      metaVar = "CNT",
+      usage = "maximum number of owned path to return (default = " + DEFAULT_LIMIT + ")")
+  public void setLimit(int limit) {
+    this.limit = limit;
+  }
+
+  @Option(
+      name = "--start",
+      aliases = {"-S"},
+      metaVar = "CNT",
+      usage = "number of owned paths to skip")
+  public void setStart(int start) {
+    this.start = start;
+  }
+
   @Option(name = "--user", usage = "user for which the owned paths should be returned")
   public void setUser(String user) {
     this.user = user;
@@ -62,14 +85,22 @@
   public Response<OwnedPathsInfo> apply(RevisionResource revisionResource)
       throws BadRequestException, ResourceConflictException, UnresolvableAccountException,
           ConfigInvalidException, IOException {
+    validateStartAndLimit();
+
     Account.Id accountId = resolveAccount();
 
     ImmutableList<Path> ownedPaths =
         codeOwnerApprovalCheck.getOwnedPaths(
-            revisionResource.getNotes(), revisionResource.getPatchSet(), accountId, /* limit= */ 0);
+            revisionResource.getNotes(),
+            revisionResource.getPatchSet(),
+            accountId,
+            start,
+            limit + 1);
 
     OwnedPathsInfo ownedPathsInfo = new OwnedPathsInfo();
-    ownedPathsInfo.ownedPaths = ownedPaths.stream().map(Path::toString).collect(toImmutableList());
+    ownedPathsInfo.more = ownedPaths.size() > limit ? true : null;
+    ownedPathsInfo.ownedPaths =
+        ownedPaths.stream().limit(limit).map(Path::toString).collect(toImmutableList());
     return Response.ok(ownedPathsInfo);
   }
 
@@ -82,4 +113,13 @@
 
     return accountResolver.resolve(user).asUnique().account().id();
   }
+
+  private void validateStartAndLimit() throws BadRequestException {
+    if (start < 0) {
+      throw new BadRequestException("start cannot be negative");
+    }
+    if (limit <= 0) {
+      throw new BadRequestException("limit must be positive");
+    }
+  }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/testing/OwnedPathsInfoSubject.java b/java/com/google/gerrit/plugins/codeowners/testing/OwnedPathsInfoSubject.java
index df6b1d0..d6a1072 100644
--- a/java/com/google/gerrit/plugins/codeowners/testing/OwnedPathsInfoSubject.java
+++ b/java/com/google/gerrit/plugins/codeowners/testing/OwnedPathsInfoSubject.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertAbout;
 
+import com.google.common.truth.BooleanSubject;
 import com.google.common.truth.FailureMetadata;
 import com.google.common.truth.IterableSubject;
 import com.google.common.truth.Subject;
@@ -48,6 +49,10 @@
     return check("ownedPaths()").that(ownedPathsInfo().ownedPaths);
   }
 
+  public BooleanSubject hasMoreThat() {
+    return check("more()").that(ownedPathsInfo().more);
+  }
+
   private OwnedPathsInfo ownedPathsInfo() {
     isNotNull();
     return ownedPathsInfo;
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetOwnedPathsIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetOwnedPathsIT.java
index 6040196..681513b 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetOwnedPathsIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetOwnedPathsIT.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersIT;
 import com.google.gerrit.plugins.codeowners.api.OwnedPathsInfo;
+import com.google.gerrit.plugins.codeowners.restapi.GetOwnedPaths;
 import com.google.gerrit.plugins.codeowners.util.JgitPath;
 import com.google.inject.Inject;
 import org.junit.Test;
@@ -33,6 +34,10 @@
 /**
  * Acceptance test for the {@link com.google.gerrit.plugins.codeowners.restapi.GetOwnedPaths} REST
  * endpoint.
+ *
+ * <p>Further tests for the {@link com.google.gerrit.plugins.codeowners.restapi.GetOwnedPaths} REST
+ * endpoint that require using the REST API are implemented in {@link
+ * com.google.gerrit.plugins.codeowners.acceptance.restapi.GetOwnedPathRestIT}.
  */
 public class GetOwnedPathsIT extends AbstractCodeOwnersIT {
   @Inject private RequestScopeOperations requestScopeOperations;
@@ -126,6 +131,7 @@
             .forUser(user.email())
             .get();
     assertThat(ownedPathsInfo).hasOwnedPathsThat().containsExactly(path1, path2).inOrder();
+    assertThat(ownedPathsInfo).hasMoreThat().isNull();
   }
 
   @Test
@@ -179,4 +185,260 @@
             .get();
     assertThat(ownedPathsInfo).hasOwnedPathsThat().isEmpty();
   }
+
+  @Test
+  public void getOwnedPathsWithStart() throws Exception {
+    setAsRootCodeOwners(user);
+
+    String path1 = "/bar/baz.md";
+    String path2 = "/bar/foo.md";
+    String path3 = "/foo/bar/baz.md";
+    String path4 = "/foo/baz/bar.md";
+
+    String changeId =
+        createChange(
+                "test change",
+                ImmutableMap.of(
+                    JgitPath.of(path1).get(),
+                    "file content",
+                    JgitPath.of(path2).get(),
+                    "file content",
+                    JgitPath.of(path3).get(),
+                    "file content",
+                    JgitPath.of(path4).get(),
+                    "file content"))
+            .getChangeId();
+
+    OwnedPathsInfo ownedPathsInfo =
+        changeCodeOwnersApiFactory
+            .change(changeId)
+            .current()
+            .getOwnedPaths()
+            .withStart(0)
+            .forUser(user.email())
+            .get();
+    assertThat(ownedPathsInfo)
+        .hasOwnedPathsThat()
+        .containsExactly(path1, path2, path3, path4)
+        .inOrder();
+
+    ownedPathsInfo =
+        changeCodeOwnersApiFactory
+            .change(changeId)
+            .current()
+            .getOwnedPaths()
+            .withStart(1)
+            .forUser(user.email())
+            .get();
+    assertThat(ownedPathsInfo).hasOwnedPathsThat().containsExactly(path2, path3, path4).inOrder();
+
+    ownedPathsInfo =
+        changeCodeOwnersApiFactory
+            .change(changeId)
+            .current()
+            .getOwnedPaths()
+            .withStart(2)
+            .forUser(user.email())
+            .get();
+    assertThat(ownedPathsInfo).hasOwnedPathsThat().containsExactly(path3, path4).inOrder();
+
+    ownedPathsInfo =
+        changeCodeOwnersApiFactory
+            .change(changeId)
+            .current()
+            .getOwnedPaths()
+            .withStart(3)
+            .forUser(user.email())
+            .get();
+    assertThat(ownedPathsInfo).hasOwnedPathsThat().containsExactly(path4);
+
+    ownedPathsInfo =
+        changeCodeOwnersApiFactory
+            .change(changeId)
+            .current()
+            .getOwnedPaths()
+            .withStart(4)
+            .forUser(user.email())
+            .get();
+    assertThat(ownedPathsInfo).hasOwnedPathsThat().isEmpty();
+  }
+
+  @Test
+  public void getOwnedPathsWithLimit() throws Exception {
+    setAsRootCodeOwners(user);
+
+    String path1 = "/bar/baz.md";
+    String path2 = "/bar/foo.md";
+    String path3 = "/foo/bar/baz.md";
+    String path4 = "/foo/baz/bar.md";
+
+    String changeId =
+        createChange(
+                "test change",
+                ImmutableMap.of(
+                    JgitPath.of(path1).get(),
+                    "file content",
+                    JgitPath.of(path2).get(),
+                    "file content",
+                    JgitPath.of(path3).get(),
+                    "file content",
+                    JgitPath.of(path4).get(),
+                    "file content"))
+            .getChangeId();
+
+    OwnedPathsInfo ownedPathsInfo =
+        changeCodeOwnersApiFactory
+            .change(changeId)
+            .current()
+            .getOwnedPaths()
+            .withLimit(1)
+            .forUser(user.email())
+            .get();
+    assertThat(ownedPathsInfo).hasOwnedPathsThat().containsExactly(path1);
+    assertThat(ownedPathsInfo).hasMoreThat().isTrue();
+
+    ownedPathsInfo =
+        changeCodeOwnersApiFactory
+            .change(changeId)
+            .current()
+            .getOwnedPaths()
+            .withLimit(2)
+            .forUser(user.email())
+            .get();
+    assertThat(ownedPathsInfo).hasOwnedPathsThat().containsExactly(path1, path2).inOrder();
+    assertThat(ownedPathsInfo).hasMoreThat().isTrue();
+
+    ownedPathsInfo =
+        changeCodeOwnersApiFactory
+            .change(changeId)
+            .current()
+            .getOwnedPaths()
+            .withLimit(3)
+            .forUser(user.email())
+            .get();
+    assertThat(ownedPathsInfo).hasOwnedPathsThat().containsExactly(path1, path2, path3).inOrder();
+    assertThat(ownedPathsInfo).hasMoreThat().isTrue();
+
+    ownedPathsInfo =
+        changeCodeOwnersApiFactory
+            .change(changeId)
+            .current()
+            .getOwnedPaths()
+            .withLimit(4)
+            .forUser(user.email())
+            .get();
+    assertThat(ownedPathsInfo)
+        .hasOwnedPathsThat()
+        .containsExactly(path1, path2, path3, path4)
+        .inOrder();
+    assertThat(ownedPathsInfo).hasMoreThat().isNull();
+  }
+
+  @Test
+  public void getOwnedPathsWithStartAndLimit() throws Exception {
+    setAsRootCodeOwners(user);
+
+    String path1 = "/bar/baz.md";
+    String path2 = "/bar/foo.md";
+    String path3 = "/foo/bar/baz.md";
+    String path4 = "/foo/baz/bar.md";
+
+    String changeId =
+        createChange(
+                "test change",
+                ImmutableMap.of(
+                    JgitPath.of(path1).get(),
+                    "file content",
+                    JgitPath.of(path2).get(),
+                    "file content",
+                    JgitPath.of(path3).get(),
+                    "file content",
+                    JgitPath.of(path4).get(),
+                    "file content"))
+            .getChangeId();
+
+    OwnedPathsInfo ownedPathsInfo =
+        changeCodeOwnersApiFactory
+            .change(changeId)
+            .current()
+            .getOwnedPaths()
+            .withStart(1)
+            .withLimit(2)
+            .forUser(user.email())
+            .get();
+    assertThat(ownedPathsInfo).hasOwnedPathsThat().containsExactly(path2, path3).inOrder();
+    assertThat(ownedPathsInfo).hasMoreThat().isTrue();
+  }
+
+  @Test
+  public void getOwnedPathsLimitedByDefault() throws Exception {
+    setAsRootCodeOwners(user);
+
+    ImmutableMap.Builder<String, String> files = ImmutableMap.builder();
+    for (int i = 1; i <= GetOwnedPaths.DEFAULT_LIMIT + 1; i++) {
+      files.put(String.format("foo-%d.txt", i), "file content");
+    }
+
+    String changeId = createChange("test change", files.build()).getChangeId();
+
+    OwnedPathsInfo ownedPathsInfo =
+        changeCodeOwnersApiFactory
+            .change(changeId)
+            .current()
+            .getOwnedPaths()
+            .forUser(user.email())
+            .get();
+    assertThat(ownedPathsInfo).hasOwnedPathsThat().hasSize(GetOwnedPaths.DEFAULT_LIMIT);
+  }
+
+  @Test
+  public void startCannotBeNegative() throws Exception {
+    String changeId = createChange().getChangeId();
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                changeCodeOwnersApiFactory
+                    .change(changeId)
+                    .current()
+                    .getOwnedPaths()
+                    .withStart(-1)
+                    .forUser(user.email())
+                    .get());
+    assertThat(exception).hasMessageThat().isEqualTo("start cannot be negative");
+  }
+
+  @Test
+  public void limitCannotBeNegative() throws Exception {
+    String changeId = createChange().getChangeId();
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                changeCodeOwnersApiFactory
+                    .change(changeId)
+                    .current()
+                    .getOwnedPaths()
+                    .withLimit(-1)
+                    .forUser(user.email())
+                    .get());
+    assertThat(exception).hasMessageThat().isEqualTo("limit must be positive");
+  }
+
+  @Test
+  public void cannotGetOwnedPathWithoutLimit() throws Exception {
+    String changeId = createChange().getChangeId();
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                changeCodeOwnersApiFactory
+                    .change(changeId)
+                    .current()
+                    .getOwnedPaths()
+                    .withLimit(0)
+                    .forUser(user.email())
+                    .get());
+    assertThat(exception).hasMessageThat().isEqualTo("limit must be positive");
+  }
 }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/GetOwnedPathRestIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/GetOwnedPathRestIT.java
new file mode 100644
index 0000000..e5287df
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/GetOwnedPathRestIT.java
@@ -0,0 +1,72 @@
+// Copyright (C) 2021 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.plugins.codeowners.acceptance.restapi;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.stream.Collectors.joining;
+
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersIT;
+import java.util.Arrays;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Acceptance test for the {@link com.google.gerrit.plugins.codeowners.restapi.GetOwnedPaths} REST
+ * endpoint. that require using via REST.
+ *
+ * <p>Acceptance test for the {@link com.google.gerrit.plugins.codeowners.restapi.GetOwnedPaths}
+ * REST endpoint that can use the Java API are implemented in {@link
+ * com.google.gerrit.plugins.codeowners.acceptance.api.GetOwnedPathsIT}.
+ */
+public class GetOwnedPathRestIT extends AbstractCodeOwnersIT {
+  private String changeId;
+
+  @Before
+  public void createTestChange() throws Exception {
+    changeId = createChange().getChangeId();
+  }
+
+  @Test
+  public void cannotGetOwnedPathsWithInvalidStart() throws Exception {
+    RestResponse r = adminRestSession.get(getUrl("user=" + user.email(), "start=invalid"));
+    r.assertBadRequest();
+    assertThat(r.getEntityContent()).contains("\"invalid\" is not a valid value for \"--start\"");
+  }
+
+  @Test
+  public void cannotGetOwnedPathsWithInvalidLimit() throws Exception {
+    RestResponse r = adminRestSession.get(getUrl("user=" + user.email(), "limit=invalid"));
+    r.assertBadRequest();
+    assertThat(r.getEntityContent()).contains("\"invalid\" is not a valid value for \"--limit\"");
+  }
+
+  private String getUrl(String... parameters) {
+    StringBuilder b = new StringBuilder();
+    b.append(getUrl());
+    String paramaterString = Arrays.stream(parameters).collect(joining("&"));
+    if (!paramaterString.isEmpty()) {
+      b.append('?').append(paramaterString);
+    }
+    return b.toString();
+  }
+
+  private String getUrl() {
+    return String.format(
+        "/changes/%s/revisions/%s/owned_paths",
+        IdString.fromDecoded(changeId), IdString.fromDecoded("current"));
+  }
+}
diff --git a/resources/Documentation/rest-api.md b/resources/Documentation/rest-api.md
index 40fb545..9c0245c 100644
--- a/resources/Documentation/rest-api.md
+++ b/resources/Documentation/rest-api.md
@@ -657,9 +657,11 @@
 
 The following request parameters can be specified:
 
-| Field Name  |           | Description |
-| ----------- | --------- | ----------- |
-| `user`      | mandatory | user for which the owned paths should be returned
+| Field Name   |           | Description |
+| ------------ | --------- | ----------- |
+| `start`\|`S` | optional  | Number of owned paths to skip. Allows to page over the owned files. By default 0.
+| `limit`\|`n` | optional  | Limit defining how many owned files should be returned at most. By default 50.
+| `user`       | mandatory | user for which the owned paths should be returned
 
 #### Request
 
@@ -960,9 +962,10 @@
 The `OwnedPathsInfo` entity contains paths that are owned by a user.
 
 
-| Field Name    | Description |
-| ------------- | ----------- |
-| `owned_paths` | The owned paths as absolute paths, sorted alphabetically.
+| Field Name    |          | Description |
+| ------------- | -------- | ----------- |
+| `owned_paths` |          |The owned paths as absolute paths, sorted alphabetically.
+| `more`        | optional | Whether the request would deliver more results if not limited. Not set if `false`.
 
 ### <a id="path-code-owner-status-info"> PathCodeOwnerStatusInfo
 The `PathCodeOwnerStatusInfo` entity describes the code owner status for a path