Skip to main content

Overview

The pagination system implements Google AIP-132 standard for List methods, providing secure, efficient pagination with support for filtering and ordering. It includes specialized builders for both list and search queries.

Page Token Types

Page tokens encode the position for the next page of results. Multiple encoding strategies are available:

Plain

No encoding - useful for development and debugging

Base64

Base64 encoding - simple obfuscation

AES256

AES-256 symmetric encryption - secure with shared key

RSA

RSA asymmetric encryption - secure with key pairs
Use encrypted page tokens (AES256 or RSA) in production to prevent token tampering and information disclosure.

List Queries

Basic List Query

use bomboni_request::query::list::{ListQueryBuilder, ListQueryConfig};
use bomboni_request::query::page_token::plain::PlainPageTokenBuilder;
use bomboni_request::ordering::{OrderingTerm, OrderingDirection};
use bomboni_request::testing::schema::UserItem;
use std::collections::BTreeMap;

// Create list query builder
let list_builder = ListQueryBuilder::new(
    UserItem::get_schema(),
    BTreeMap::new(),
    ListQueryConfig {
        max_page_size: Some(100),
        default_page_size: 20,
        primary_ordering_term: Some(OrderingTerm {
            name: "id".into(),
            direction: OrderingDirection::Ascending,
        }),
        max_filter_length: Some(1000),
        max_ordering_length: Some(100),
    },
    PlainPageTokenBuilder {},
);

// Build list query
let list_query = list_builder.build(
    Some(50),                                    // page_size
    None,                                        // page_token
    Some(r#"displayName = "John""#),             // filter
    Some("age desc")                             // ordering
).unwrap();

assert_eq!(list_query.page_size, 50);
assert_eq!(list_query.filter.to_string(), r#"displayName = "John""#);
assert_eq!(list_query.ordering.to_string(), "id asc, age desc");

List Query Configuration

max_page_size
Option<i32>
Maximum allowed page size. Requests exceeding this are automatically clamped.
default_page_size
i32
Default page size when client doesn’t specify one.
primary_ordering_term
Option<OrderingTerm>
Primary ordering term prepended to all queries. Should be a unique field like id to ensure deterministic pagination.
max_filter_length
Option<usize>
Maximum allowed filter string length in characters.
max_ordering_length
Option<usize>
Maximum allowed ordering string length in characters.

Building Next Page Tokens

use bomboni_request::testing::schema::UserItem;

// Assume we fetched page_size + 1 items
let users = vec![
    // ... first page of results
];

if users.len() > list_query.page_size as usize {
    // There's a next page
    let last_item = &users[list_query.page_size as usize - 1];
    
    let next_page_token = list_builder
        .build_next_page_token(&list_query, last_item)
        .unwrap();
    
    // Return next_page_token to client
}

Search Queries

Basic Search Query

Search queries extend list queries with a text search query:
use bomboni_request::query::search::{SearchQueryBuilder, SearchQueryConfig};
use bomboni_request::query::page_token::plain::PlainPageTokenBuilder;
use bomboni_request::ordering::{OrderingTerm, OrderingDirection};
use bomboni_request::testing::schema::UserItem;
use std::collections::BTreeMap;

// Create search query builder
let search_builder = SearchQueryBuilder::new(
    UserItem::get_schema(),
    BTreeMap::new(),
    SearchQueryConfig {
        max_query_length: Some(100),
        max_page_size: Some(20),
        default_page_size: 10,
        primary_ordering_term: Some(OrderingTerm {
            name: "id".into(),
            direction: OrderingDirection::Descending,
        }),
        max_filter_length: Some(1000),
        max_ordering_length: Some(100),
    },
    PlainPageTokenBuilder {},
);

// Build search query
let search_query = search_builder.build(
    "john doe",                                  // search query text
    Some(25),                                    // page_size
    None,                                        // page_token
    Some(r#"age >= 18 AND displayName = "John""#), // filter
    Some("age desc, displayName asc")             // ordering
).unwrap();

assert_eq!(search_query.query, "john doe");
assert_eq!(search_query.page_size, 20); // Clamped to max_page_size
assert_eq!(search_query.filter.to_string(), r#"age >= 18 AND displayName = "John""#);
assert_eq!(search_query.ordering.to_string(), "id desc, age desc, displayName asc");

Search Query Configuration

Extends ListQueryConfig with:
max_query_length
Option<usize>
Maximum allowed search query string length.

Encrypted Page Tokens

AES256 (Symmetric Encryption)

use bomboni_request::query::list::Aes256ListQueryBuilder;
use bomboni_request::query::page_token::aes256::Aes256PageTokenBuilder;

// Generate a 32-byte key
let key = b"your-32-byte-secret-key-here!!!!";

let builder = Aes256ListQueryBuilder::new(
    schema,
    schema_functions,
    config,
    Aes256PageTokenBuilder::new(*key),
);

// Tokens are now encrypted with AES-256
let query = builder.build(Some(20), None, None, None).unwrap();

RSA (Asymmetric Encryption)

use bomboni_request::query::list::RsaListQueryBuilder;
use bomboni_request::query::page_token::rsa::RsaPageTokenBuilder;
use rsa::{RsaPrivateKey, RsaPublicKey};

// Generate RSA key pair
let mut rng = rand::thread_rng();
let private_key = RsaPrivateKey::new(&mut rng, 2048).unwrap();
let public_key = RsaPublicKey::from(&private_key);

let builder = RsaListQueryBuilder::new(
    schema,
    schema_functions,
    config,
    RsaPageTokenBuilder::new(private_key, public_key),
);

// Tokens are now RSA encrypted
let query = builder.build(Some(20), None, None, None).unwrap();

Base64 Encoding

use bomboni_request::query::list::Base64ListQueryBuilder;
use bomboni_request::query::page_token::base64::Base64PageTokenBuilder;

let builder = Base64ListQueryBuilder::new(
    schema,
    schema_functions,
    config,
    Base64PageTokenBuilder {},
);

// Tokens are base64 encoded (not secure, but obfuscated)
let query = builder.build(Some(20), None, None, None).unwrap();

Parse Derive Integration

Automate query parsing with the Parse derive macro:
use bomboni_request_derive::Parse;
use bomboni_request::parse::RequestParse;
use bomboni_request::query::list::ListQuery;

#[derive(Debug, Clone, PartialEq, Default)]
struct ListUsersRequest {
    page_size: Option<u32>,
    page_token: Option<String>,
    filter: Option<String>,
    order_by: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Parse)]
#[parse(source = ListUsersRequest, write)]
struct ParsedListUsersRequest {
    #[parse(list_query)]
    query: ListQuery,
}

// Parse request automatically
let request = ListUsersRequest {
    page_size: Some(50),
    page_token: None,
    filter: Some(r#"displayName = "John""#.into()),
    order_by: Some("age desc".into()),
};

let parsed = ParsedListUsersRequest::parse_list_query(request, &list_builder).unwrap();
assert_eq!(parsed.query.page_size, 50);

Search Query with Parse Derive

#[derive(Debug, Clone, PartialEq, Default)]
struct SearchUsersRequest {
    query: String,
    page_size: Option<u32>,
    page_token: Option<String>,
    filter: Option<String>,
    order_by: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Parse)]
#[parse(source = SearchUsersRequest, write)]
struct ParsedSearchUsersRequest {
    #[parse(search_query)]
    query: SearchQuery,
}

let request = SearchUsersRequest {
    query: "john doe".to_string(),
    page_size: Some(25),
    page_token: None,
    filter: Some(r#"age >= 18"#.into()),
    order_by: Some("age desc".into()),
};

let parsed = ParsedSearchUsersRequest::parse_search_query(request, &search_builder).unwrap();
assert_eq!(parsed.query.query, "john doe");

Complete API Example

use bomboni_request::query::list::{ListQueryBuilder, ListQueryConfig};
use bomboni_request::query::page_token::aes256::Aes256PageTokenBuilder;
use bomboni_request::ordering::{OrderingTerm, OrderingDirection};
use std::collections::BTreeMap;

// Initialize builder (typically done once at startup)
let builder = ListQueryBuilder::new(
    UserItem::get_schema(),
    BTreeMap::new(),
    ListQueryConfig {
        max_page_size: Some(100),
        default_page_size: 20,
        primary_ordering_term: Some(OrderingTerm {
            name: "id".into(),
            direction: OrderingDirection::Ascending,
        }),
        max_filter_length: Some(1000),
        max_ordering_length: Some(100),
    },
    Aes256PageTokenBuilder::new(*b"your-32-byte-secret-key-here!!!!"),
);

// In your API handler
fn list_users(
    page_size: Option<i32>,
    page_token: Option<String>,
    filter: Option<String>,
    order_by: Option<String>,
) -> Result<ListUsersResponse, Error> {
    // Parse and validate request
    let query = builder.build(
        page_size,
        page_token.as_deref(),
        filter.as_deref(),
        order_by.as_deref(),
    )?;
    
    // Fetch page_size + 1 items to determine if there's a next page
    let limit = query.page_size + 1;
    let users = fetch_users_from_db(&query.filter, &query.ordering, limit)?;
    
    // Build response
    let has_next = users.len() > query.page_size as usize;
    let mut response_users = users;
    if has_next {
        response_users.truncate(query.page_size as usize);
    }
    
    let next_page_token = if has_next {
        Some(builder.build_next_page_token(&query, users.last().unwrap())?)
    } else {
        None
    };
    
    Ok(ListUsersResponse {
        users: response_users,
        next_page_token,
    })
}

Best Practices

1

Use Encrypted Tokens in Production

Always use AES256 or RSA page tokens in production environments to prevent tampering
2

Fetch N+1 Items

Fetch page_size + 1 items to efficiently determine if there’s a next page without a separate count query
3

Set Primary Ordering

Always configure a primary_ordering_term using a unique field (like id) to ensure stable pagination
4

Configure Limits

Set appropriate max_page_size, max_filter_length, and max_ordering_length to prevent abuse
5

Validate Tokens

Page token builders automatically validate that tokens match the current filter and ordering
Page tokens are tied to specific filter and ordering parameters. If a client changes these parameters, previous page tokens become invalid.

Error Handling

use bomboni_request::query::error::QueryError;

match builder.build(Some(-1), None, None, None) {
    Err(QueryError::InvalidPageSize) => {
        // Handle invalid page size
    }
    Err(QueryError::FilterTooLong) => {
        // Handle filter exceeding max length
    }
    Err(QueryError::PageTokenInvalid) => {
        // Handle invalid/tampered page token
    }
    Ok(query) => {
        // Process query
    }
    Err(e) => {
        // Handle other errors
    }
}

Filtering

Learn about filter expressions

Ordering

Understand ordering specifications

Parse Derive

Auto-generate query parsers

Build docs developers (and LLMs) love