Pagination
The perms.io API uses cursor-based pagination for efficient traversal of large result sets. This approach provides consistent performance regardless of the dataset size and handles real-time data changes gracefully.
Overview
Cursor-based pagination uses opaque tokens to mark positions in result sets, making it ideal for: - Large datasets with frequent updates - Real-time applications requiring consistent performance - APIs that need to handle concurrent modifications
Pagination Request
All list endpoints support optional pagination parameters:
message PaginationRequest {
// The number of items to return. Must be between 1 and 100. Default is 10.
optional int32 limit = 1;
// The cursor to start from. If not provided, starts from the beginning.
optional string cursor = 2;
}
Parameters
- limit: Number of items per page (1-100, default: 10)
- cursor: Token indicating where to start the next page
Pagination Response
List endpoints return pagination metadata in their responses:
message PaginationResponse {
// The next cursor to use for the next page of results. If there are no more pages, this will be empty.
string next_cursor = 1;
}
Response Fields
- next_cursor: Token for the next page (empty if no more pages)
Usage Examples
Basic Pagination
curl -X GET "https://api.perms.io/permissions-service/v1/permissions?project_name=production&limit=10" \
-H "Authorization: Bearer YOUR_API_KEY"
{
"permissions": [...],
"pagination": {
"next_cursor": "eyJpZCI6IjEyMyIsInRzIjoiMjAyNC0wMS0xNVQxMDowMDowMFoifQ=="
}
}
func listAllPermissions(client permissionsv1.PermissionsServiceClient, projectName string) {
var allPermissions []*permissionsv1.Permission
var cursor *string
for {
req := &permissionsv1.ListPermissionsRequest{
ProjectName: projectName,
Pagination: &paginationv1.PaginationRequest{
Limit: proto.Int32(50),
},
}
if cursor != nil {
req.Pagination.Cursor = cursor
}
resp, err := client.ListPermissions(context.Background(), req)
if err != nil {
log.Fatalf("Failed to list permissions: %v", err)
}
allPermissions = append(allPermissions, resp.Permissions...)
// Check if there are more pages
if resp.Pagination.NextCursor == "" {
break
}
cursor = &resp.Pagination.NextCursor
}
log.Printf("Retrieved %d total permissions", len(allPermissions))
}
use permio_proto::permissions::v1::{
permissions_service_client::PermissionsServiceClient,
ListPermissionsRequest,
};
use permio_proto::pagination::PaginationRequest;
use tonic::transport::Channel;
async fn list_all_permissions(client: &mut PermissionsServiceClient<Channel>, project_name: &str) -> Result<Vec<Permission>, Box<dyn std::error::Error>> {
let mut all_permissions = Vec::new();
let mut cursor: Option<String> = None;
loop {
let mut pagination = PaginationRequest {
limit: Some(50),
cursor: cursor.clone(),
};
let request = tonic::Request::new(ListPermissionsRequest {
project_name: project_name.to_string(),
pagination: Some(pagination),
search: None,
});
let response = client.list_permissions(request).await?;
let list_response = response.get_ref();
all_permissions.extend(list_response.permissions.clone());
// Check if there are more pages
if let Some(pagination_response) = &list_response.pagination {
if pagination_response.next_cursor.is_empty() {
break;
}
cursor = Some(pagination_response.next_cursor.clone());
} else {
break;
}
}
println!("Retrieved {} total permissions", all_permissions.len());
Ok(all_permissions)
}
def list_all_permissions(client, project_name: str):
all_permissions = []
cursor = None
while True:
request = permissions_service_pb2.ListPermissionsRequest(
project_name=project_name,
pagination=pagination_pb2.PaginationRequest(limit=50)
)
if cursor:
request.pagination.cursor = cursor
response = client.ListPermissions(request)
all_permissions.extend(response.permissions)
# Check if there are more pages
if not response.pagination.next_cursor:
break
cursor = response.pagination.next_cursor
print(f"Retrieved {len(all_permissions)} total permissions")
return all_permissions
import java.util.ArrayList;
import java.util.List;
import permio.permissions.v1.PermissionsServiceGrpc;
import permio.permissions.v1.Permissions.ListPermissionsRequest;
import permio.permissions.v1.Permissions.ListPermissionsResponse;
import permio.permissions.v1.Permissions.Permission;
import permio.pagination.PaginationOuterClass.PaginationRequest;
public List<Permission> listAllPermissions(PermissionsServiceGrpc.PermissionsServiceBlockingStub client, String projectName) {
List<Permission> allPermissions = new ArrayList<>();
String cursor = null;
while (true) {
PaginationRequest.Builder paginationBuilder = PaginationRequest.newBuilder()
.setLimit(50);
if (cursor != null) {
paginationBuilder.setCursor(cursor);
}
ListPermissionsRequest request = ListPermissionsRequest.newBuilder()
.setProjectName(projectName)
.setPagination(paginationBuilder.build())
.build();
ListPermissionsResponse response = client.listPermissions(request);
allPermissions.addAll(response.getPermissionsList());
// Check if there are more pages
if (response.getPagination().getNextCursor().isEmpty()) {
break;
}
cursor = response.getPagination().getNextCursor();
}
System.out.println("Retrieved " + allPermissions.size() + " total permissions");
return allPermissions;
}
interface ListPermissionsRequest {
project_name: string;
pagination?: {
limit?: number;
cursor?: string;
};
search?: string;
}
interface Permission {
id: string;
name: string;
description: string;
}
async function listAllPermissions(client: any, projectName: string): Promise<Permission[]> {
const allPermissions: Permission[] = [];
let cursor: string | null = null;
while (true) {
const request: ListPermissionsRequest = {
project_name: projectName,
pagination: { limit: 50 }
};
if (cursor) {
request.pagination!.cursor = cursor;
}
const response = await new Promise<any>((resolve, reject) => {
client.ListPermissions(request, (error: Error | null, response: any) => {
if (error) reject(error);
else resolve(response);
});
});
allPermissions.push(...response.permissions);
// Check if there are more pages
if (!response.pagination.next_cursor) {
break;
}
cursor = response.pagination.next_cursor;
}
console.log(`Retrieved ${allPermissions.length} total permissions`);
return allPermissions;
}
Next Page Request
curl -X GET "https://api.perms.io/permissions-service/v1/permissions?project_name=production&limit=10&cursor=eyJpZCI6IjEyMyIsInRzIjoiMjAyNC0wMS0xNVQxMDowMDowMFoifQ==" \
-H "Authorization: Bearer YOUR_API_KEY"
Handling Search with Pagination
When using search parameters, pagination works the same way:
curl -X GET "https://api.perms.io/permissions-service/v1/permissions?project_name=production&search=document&limit=10" \
-H "Authorization: Bearer YOUR_API_KEY"
func searchPermissions(client permissionsv1.PermissionsServiceClient, projectName, searchTerm string) {
req := &permissionsv1.ListPermissionsRequest{
ProjectName: projectName,
Search: proto.String(searchTerm),
Pagination: &paginationv1.PaginationRequest{
Limit: proto.Int32(20),
},
}
resp, err := client.ListPermissions(context.Background(), req)
if err != nil {
log.Fatalf("Failed to search permissions: %v", err)
}
log.Printf("Found %d permissions matching '%s'", len(resp.Permissions), searchTerm)
// Process next page if available
if resp.Pagination.NextCursor != "" {
log.Printf("Next page available with cursor: %s", resp.Pagination.NextCursor)
}
}
async fn search_permissions(client: &mut PermissionsServiceClient<Channel>, project_name: &str, search_term: &str) -> Result<(), Box<dyn std::error::Error>> {
let request = tonic::Request::new(ListPermissionsRequest {
project_name: project_name.to_string(),
search: Some(search_term.to_string()),
pagination: Some(PaginationRequest {
limit: Some(20),
cursor: None,
}),
});
let response = client.list_permissions(request).await?;
let list_response = response.get_ref();
println!("Found {} permissions matching '{}'", list_response.permissions.len(), search_term);
// Process next page if available
if let Some(pagination) = &list_response.pagination {
if !pagination.next_cursor.is_empty() {
println!("Next page available with cursor: {}", pagination.next_cursor);
}
}
Ok(())
}
def search_permissions(client, project_name: str, search_term: str):
request = permissions_service_pb2.ListPermissionsRequest(
project_name=project_name,
search=search_term,
pagination=pagination_pb2.PaginationRequest(limit=20)
)
response = client.ListPermissions(request)
print(f"Found {len(response.permissions)} permissions matching '{search_term}'")
# Process next page if available
if response.pagination.next_cursor:
print(f"Next page available with cursor: {response.pagination.next_cursor}")
public void searchPermissions(PermissionsServiceGrpc.PermissionsServiceBlockingStub client, String projectName, String searchTerm) {
ListPermissionsRequest request = ListPermissionsRequest.newBuilder()
.setProjectName(projectName)
.setSearch(searchTerm)
.setPagination(PaginationRequest.newBuilder().setLimit(20).build())
.build();
ListPermissionsResponse response = client.listPermissions(request);
System.out.println("Found " + response.getPermissionsCount() + " permissions matching '" + searchTerm + "'");
// Process next page if available
if (!response.getPagination().getNextCursor().isEmpty()) {
System.out.println("Next page available with cursor: " + response.getPagination().getNextCursor());
}
}
function searchPermissions(client: any, projectName: string, searchTerm: string): void {
const request: ListPermissionsRequest = {
project_name: projectName,
search: searchTerm,
pagination: {
limit: 20
}
};
client.ListPermissions(request, (error: Error | null, response: any) => {
if (error) {
console.error('Error:', error);
return;
}
console.log(`Found ${response.permissions.length} permissions matching '${searchTerm}'`);
// Process next page if available
if (response.pagination.next_cursor) {
console.log(`Next page available with cursor: ${response.pagination.next_cursor}`);
}
});
}
Best Practices
1. Choose Appropriate Page Sizes
- Small pages (10-25): Better for user interfaces, real-time updates
- Medium pages (50-100): Good balance for most use cases
- Large pages (100): Better for bulk operations, data processing
2. Handle Empty Results
Always check for empty result sets:
if len(resp.Permissions) == 0 {
log.Printf("No permissions found")
return
}
3. Store Cursors Appropriately
- Short-term: Store in memory or session storage
- Long-term: Store in database for resumable operations
- Never: Attempt to decode or manipulate cursor values
4. Implement Retry Logic
func listPermissionsWithRetry(client permissionsv1.PermissionsServiceClient, req *permissionsv1.ListPermissionsRequest) (*permissionsv1.ListPermissionsResponse, error) {
maxRetries := 3
for i := 0; i < maxRetries; i++ {
resp, err := client.ListPermissions(context.Background(), req)
if err == nil {
return resp, nil
}
if i == maxRetries-1 {
return nil, err
}
// Exponential backoff
time.Sleep(time.Duration(1<<i) * time.Second)
}
return nil, nil
}
5. Monitor Performance
Track pagination performance metrics: - Average response times by page size - Memory usage for large result sets - Error rates for different cursor ages
Pagination Patterns
Simple Iteration
For processing all items in a collection:
func processAllRoles(client permissionsv1.PermissionsServiceClient, projectName string) {
var cursor *string
for {
roles, nextCursor, err := getRolesPage(client, projectName, cursor, 50)
if err != nil {
log.Printf("Error fetching roles: %v", err)
break
}
// Process each role
for _, role := range roles {
processRole(role)
}
if nextCursor == "" {
break
}
cursor = &nextCursor
}
}
Concurrent Processing
For parallel processing of paginated results:
func processPrincipalsAsync(client permissionsv1.PermissionsServiceClient, projectName string) {
type pageResult struct {
principals []*string
cursor string
err error
}
resultChan := make(chan pageResult, 10)
var wg sync.WaitGroup
// Start first page
wg.Add(1)
go func() {
defer wg.Done()
principals, cursor, err := getPrincipalsPage(client, projectName, nil, 100)
resultChan <- pageResult{principals, cursor, err}
}()
// Process results and spawn next page requests
go func() {
for result := range resultChan {
if result.err != nil {
log.Printf("Error: %v", result.err)
continue
}
// Process principals
for _, principal := range result.principals {
log.Printf("Processing principal: %s", *principal)
}
// Spawn next page if available
if result.cursor != "" {
wg.Add(1)
go func(cursor string) {
defer wg.Done()
principals, nextCursor, err := getPrincipalsPage(client, projectName, &cursor, 100)
resultChan <- pageResult{principals, nextCursor, err}
}(result.cursor)
}
}
}()
wg.Wait()
close(resultChan)
}
Error Handling
Cursor Expiration
Cursors may expire after a certain period. Handle this gracefully:
func handleCursorExpiration(client permissionsv1.PermissionsServiceClient, req *permissionsv1.ListPermissionsRequest) {
resp, err := client.ListPermissions(context.Background(), req)
if err != nil {
if isCursorExpiredError(err) {
log.Printf("Cursor expired, restarting from beginning")
req.Pagination.Cursor = nil
resp, err = client.ListPermissions(context.Background(), req)
}
if err != nil {
log.Fatalf("Failed to list permissions: %v", err)
}
}
// Process response...
}
Rate Limiting
Implement rate limiting between requests:
func listWithRateLimit(client permissionsv1.PermissionsServiceClient, projectName string) {
rateLimiter := time.NewTicker(100 * time.Millisecond)
defer rateLimiter.Stop()
var cursor *string
for {
<-rateLimiter.C // Wait for rate limit
roles, nextCursor, err := getRolesPage(client, projectName, cursor, 50)
if err != nil {
log.Printf("Error: %v", err)
break
}
processRoles(roles)
if nextCursor == "" {
break
}
cursor = &nextCursor
}
}
Limitations
- Maximum limit: 100 items per page
- Cursor lifetime: Cursors expire after 24 hours
- Sort stability: Results are sorted by creation time and ID
- Concurrent modifications: Results may include items created during iteration
Endpoints Supporting Pagination
All list endpoints support pagination:
ListPermissionsListRolesListPrincipalsGetAllAssignmentsForPrincipalListAPIKeysGetProjectsForOrganisation
Migration from Offset-Based Pagination
If migrating from offset-based pagination:
// Old offset-based approach
func listPermissionsOffset(offset, limit int) {
// This approach doesn't work with cursor-based pagination
}
// New cursor-based approach
func listPermissionsCursor(cursor *string, limit int) {
req := &permissionsv1.ListPermissionsRequest{
ProjectName: "production",
Pagination: &paginationv1.PaginationRequest{
Limit: proto.Int32(int32(limit)),
},
}
if cursor != nil {
req.Pagination.Cursor = cursor
}
// Make request...
}
Testing Pagination
Unit Tests
func TestPaginationFlow(t *testing.T) {
// Mock client setup
client := &mockPermissionsClient{}
// Test first page
resp1, err := client.ListPermissions(context.Background(), &permissionsv1.ListPermissionsRequest{
ProjectName: "test",
Pagination: &paginationv1.PaginationRequest{
Limit: proto.Int32(2),
},
})
assert.NoError(t, err)
assert.Len(t, resp1.Permissions, 2)
assert.NotEmpty(t, resp1.Pagination.NextCursor)
// Test second page
resp2, err := client.ListPermissions(context.Background(), &permissionsv1.ListPermissionsRequest{
ProjectName: "test",
Pagination: &paginationv1.PaginationRequest{
Limit: proto.Int32(2),
Cursor: &resp1.Pagination.NextCursor,
},
})
assert.NoError(t, err)
assert.Len(t, resp2.Permissions, 2)
// Verify different results
assert.NotEqual(t, resp1.Permissions[0].Id, resp2.Permissions[0].Id)
}