Repository: gophercloud/gophercloud

Issue:
several paginated list endpoints are broken as of Keystone 2025.1 (Epoxy)
### What happened?

According to https://docs.openstack.org/releasenotes/keystone/2025.1.html, listing users/groups/projects/domains in Keystone now uses marker-based instead of link-based pagination.

### Version

v2 (current)

Discussion:

Solution patch:
diff --git a/openstack/identity/v3/domains/requests.go b/openstack/identity/v3/domains/requests.go
index 8ce72a9b..94bb567f 100644
--- a/openstack/identity/v3/domains/requests.go
+++ b/openstack/identity/v3/domains/requests.go
@@ -20,6 +20,9 @@ type ListOpts struct {
 
 	// Name filters the response by domain name.
 	Name string `q:"name"`
+
+	// Limit limits the number of projects returned per page.
+	Limit int `q:"limit"`
 }
 
 // ToDomainListQuery formats a ListOpts into a query string.
diff --git a/openstack/identity/v3/projects/requests.go b/openstack/identity/v3/projects/requests.go
index 6fb8448a..5aecc94d 100644
--- a/openstack/identity/v3/projects/requests.go
+++ b/openstack/identity/v3/projects/requests.go
@@ -45,6 +45,9 @@ type ListOpts struct {
 	// NotTagsAny filters on specific project tags. At least one of the tags must be absent for the project.
 	NotTagsAny string `q:"not-tags-any"`
 
+	// Limit limits the number of projects returned per page.
+	Limit int `q:"limit"`
+
 	// Filters filters the response by custom filters such as
 	// 'name__contains=foo'
 	Filters map[string]string `q:"-"`



Test patch:
diff --git a/openstack/identity/v3/projects/testing/fixtures_test.go b/openstack/identity/v3/projects/testing/fixtures_test.go
index 5c97fc7d..5782fde8 100644
--- a/openstack/identity/v3/projects/testing/fixtures_test.go
+++ b/openstack/identity/v3/projects/testing/fixtures_test.go
@@ -59,7 +59,19 @@ const ListOutput = `
       "parent_id": null,
       "tags": ["Red", "Team"],
       "test": "old"
-    },
+    }
+  ],
+  "links": {
+    "next": "%sprojects?limit=1&marker=1234",
+    "self": "%sprojects?limit=1",
+    "previous": null
+  }
+}
+`
+
+const ListOutputSecondPage = `
+{
+  "projects": [
     {
       "is_domain": false,
       "description": "The team that is blue",
@@ -73,8 +85,21 @@ const ListOutput = `
       }
     }
   ],
+  "links": {
+    "next": "%sprojects?limit=1&marker=9876",
+    "self": "%sprojects?limit=1&marker=1234",
+    "previous": null
+  }
+}
+`
+
+const ListOutputThirdPage = `
+{
+  "projects": [
+  ],
   "links": {
     "next": null,
+    "self": "%sprojects?limit=1&marker=9876",
     "previous": null
   }
 }
@@ -299,7 +324,15 @@ func HandleListProjectsSuccessfully(t *testing.T, fakeServer th.FakeServer) {
 
 		w.Header().Set("Content-Type", "application/json")
 		w.WriteHeader(http.StatusOK)
-		fmt.Fprint(w, ListOutput)
+
+		switch r.URL.Query().Get("marker") {
+		case "":
+			fmt.Fprintf(w, ListOutput, fakeServer.Endpoint(), fakeServer.Endpoint())
+		case "1234":
+			fmt.Fprintf(w, ListOutputSecondPage, fakeServer.Endpoint(), fakeServer.Endpoint())
+		case "9876":
+			fmt.Fprintf(w, ListOutputThirdPage, fakeServer.Endpoint())
+		}
 	})
 }
 
diff --git a/openstack/identity/v3/projects/testing/requests_test.go b/openstack/identity/v3/projects/testing/requests_test.go
index 5ecc88e4..ca5bebe4 100644
--- a/openstack/identity/v3/projects/testing/requests_test.go
+++ b/openstack/identity/v3/projects/testing/requests_test.go
@@ -35,19 +35,26 @@ func TestListProjects(t *testing.T) {
 	defer fakeServer.Teardown()
 	HandleListProjectsSuccessfully(t, fakeServer)
 
+	actualProjects := make([]projects.Project, 0, 2)
 	count := 0
-	err := projects.List(client.ServiceClient(fakeServer), nil).EachPage(context.TODO(), func(_ context.Context, page pagination.Page) (bool, error) {
+	opts := projects.ListOpts{
+		Limit: 1,
+	}
+	err := projects.List(client.ServiceClient(fakeServer), opts).EachPage(context.TODO(), func(_ context.Context, page pagination.Page) (bool, error) {
 		count++
 
 		actual, err := projects.ExtractProjects(page)
 		th.AssertNoErr(t, err)
 
-		th.CheckDeepEquals(t, ExpectedProjectSlice, actual)
+		actualProjects = append(actualProjects, actual...)
 
 		return true, nil
 	})
 	th.AssertNoErr(t, err)
-	th.CheckEquals(t, count, 1)
+	th.CheckEquals(t, count, 2)
+
+	th.CheckEquals(t, len(actualProjects), 2)
+	th.CheckDeepEquals(t, ExpectedProjectSlice, actualProjects)
 }
 
 func TestListGroupsFiltersCheck(t *testing.T) {