Coverage for src / mcp_server_langgraph / api / pagination.py: 79%
51 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-03 00:43 +0000
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-03 00:43 +0000
1"""
2Standardized Pagination Models
4Provides consistent pagination across all list endpoints for production-grade APIs.
6Usage:
7 ```python
8 from mcp_server_langgraph.api.pagination import PaginationParams, PaginatedResponse
10 @router.get("/items", response_model=PaginatedResponse[Item])
11 async def list_items(pagination: PaginationParams = Depends()):
12 # Use pagination.offset and pagination.limit for queries
13 items = await db.query().offset(pagination.offset).limit(pagination.limit).all()
14 total = await db.query().count()
16 return create_paginated_response(
17 data=items,
18 total=total,
19 page=pagination.page,
20 page_size=pagination.page_size
21 )
22 ```
23"""
25import math
26from typing import Generic, TypeVar
28from pydantic import BaseModel, Field, computed_field, field_validator
30# Generic type for paginated data
31T = TypeVar("T")
34class PaginationParams(BaseModel):
35 """
36 Pagination parameters for list endpoints
38 Supports both page-based and offset-based pagination:
39 - Page-based: page + page_size
40 - Offset-based: offset + limit
42 Page-based is automatically converted to offset/limit for database queries.
43 """
45 page: int = Field(default=1, ge=1, description="Page number (1-indexed)", examples=[1, 2, 10])
46 page_size: int = Field(
47 default=20, ge=1, le=1000, description="Number of items per page (max: 1000)", examples=[20, 50, 100]
48 )
50 @field_validator("page_size")
51 @classmethod
52 def limit_page_size(cls, v: int) -> int:
53 """Enforce maximum page size to prevent excessive queries"""
54 if v > 1000:
55 return 1000
56 return v
58 @computed_field # type: ignore[prop-decorator]
59 @property
60 def offset(self) -> int:
61 """Calculate offset from page and page_size (for database queries)"""
62 return (self.page - 1) * self.page_size
64 @computed_field # type: ignore[prop-decorator]
65 @property
66 def limit(self) -> int:
67 """Alias for page_size (for database queries)"""
68 return self.page_size
70 model_config = {
71 "json_schema_extra": {
72 "examples": [
73 {"page": 1, "page_size": 20, "offset": 0, "limit": 20},
74 {"page": 3, "page_size": 50, "offset": 100, "limit": 50},
75 ]
76 }
77 }
80class PaginationMetadata(BaseModel):
81 """
82 Pagination metadata included in responses
84 Provides information for clients to navigate pages.
85 """
87 total: int = Field(description="Total number of items across all pages", examples=[100, 1000])
88 page: int = Field(ge=1, description="Current page number (1-indexed)", examples=[1, 2, 10])
89 page_size: int = Field(ge=1, description="Number of items per page", examples=[20, 50, 100])
90 total_pages: int = Field(ge=0, description="Total number of pages", examples=[5, 20, 100])
92 @computed_field # type: ignore[prop-decorator]
93 @property
94 def has_next(self) -> bool:
95 """Whether there is a next page"""
96 return self.page < self.total_pages
98 @computed_field # type: ignore[prop-decorator]
99 @property
100 def has_prev(self) -> bool:
101 """Whether there is a previous page"""
102 return self.page > 1
104 @computed_field # type: ignore[prop-decorator]
105 @property
106 def next_page(self) -> int | None:
107 """Next page number (None if on last page)"""
108 return self.page + 1 if self.has_next else None
110 @computed_field # type: ignore[prop-decorator]
111 @property
112 def prev_page(self) -> int | None:
113 """Previous page number (None if on first page)"""
114 return self.page - 1 if self.has_prev else None
116 model_config = {
117 "json_schema_extra": {
118 "examples": [
119 {
120 "total": 100,
121 "page": 2,
122 "page_size": 20,
123 "total_pages": 5,
124 "has_next": True,
125 "has_prev": True,
126 "next_page": 3,
127 "prev_page": 1,
128 }
129 ]
130 }
131 }
134class PaginatedResponse(BaseModel, Generic[T]):
135 """
136 Standardized paginated response wrapper
138 Generic type allows type-safe responses for any data type.
140 Example:
141 PaginatedResponse[APIKeyResponse] for API keys
142 PaginatedResponse[UserResponse] for users
143 """
145 data: list[T] = Field(description="Array of items for the current page")
146 pagination: PaginationMetadata = Field(description="Pagination metadata for navigation")
148 model_config = {
149 "json_schema_extra": {
150 "examples": [
151 {
152 "data": [{"id": "1", "name": "Item 1"}, {"id": "2", "name": "Item 2"}],
153 "pagination": {
154 "total": 100,
155 "page": 1,
156 "page_size": 20,
157 "total_pages": 5,
158 "has_next": True,
159 "has_prev": False,
160 "next_page": 2,
161 "prev_page": None,
162 },
163 }
164 ]
165 }
166 }
169def create_paginated_response(data: list[T], total: int, page: int, page_size: int) -> PaginatedResponse[T]:
170 """
171 Helper function to create paginated responses
173 Args:
174 data: List of items for current page
175 total: Total number of items across all pages
176 page: Current page number (1-indexed)
177 page_size: Number of items per page
179 Returns:
180 PaginatedResponse with data and pagination metadata
182 Example:
183 ```python
184 items = await db.query().offset(offset).limit(limit).all()
185 total = await db.query().count()
187 return create_paginated_response(
188 data=items,
189 total=total,
190 page=page,
191 page_size=page_size
192 )
193 ```
194 """
195 total_pages = math.ceil(total / page_size) if total > 0 else 0
197 return PaginatedResponse(
198 data=data, pagination=PaginationMetadata(total=total, page=page, page_size=page_size, total_pages=total_pages)
199 )