Skip to content

Commit 670aaa6

Browse files
Add pagination support to the API (#211)
## Ticket Resolves #210 ## Changes This adds pagination support to the API schema and DB queries Added a new `POST /users/search` endpoint to have an example of a paginated endpoint. ## Context for reviewers There are likely more features that could be built ontop of this (multi-field sorting, Paginator class as an iterator, and a few other utilities), but was focused on getting the core functionality of pagination working in a fairly general manner. This approach for pagination is based on a mix of past projects and partially based on the [Flask-SQLAlchemy](https://github.com/pallets-eco/flask-sqlalchemy/blob/d349bdb6229fb5893ddfc7a6ff273425e4c1da7a/src/flask_sqlalchemy/pagination.py) libraries approach. ## Testing Added a bunch of users locally by calling the POST /users endpoint, but only one which would be found by the following query: ```json { "is_active": true, "paging": { "page_offset": 1, "page_size": 25 }, "phone_number": "123-456-7890", "role_type": "USER", "sorting": { "order_by": "id", "sort_direction": "ascending" } } ``` And got the following response (with the data removed as it's a lot): ```json { "data": [...], "errors": [], "message": "Success", "pagination_info": { "order_by": "id", "page_offset": 1, "page_size": 25, "sort_direction": "ascending", "total_pages": 2, "total_records": 41 }, "status_code": 200, "warnings": [] } ``` Further testing was done, and can be seen in the unit tests to verify the paging/sorting behavior. --------- Co-authored-by: nava-platform-bot <[email protected]>
1 parent e48e359 commit 670aaa6

File tree

16 files changed

+773
-29
lines changed

16 files changed

+773
-29
lines changed

app/openapi.generated.yml

Lines changed: 155 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ paths:
3636
type: array
3737
items:
3838
$ref: '#/components/schemas/ValidationError'
39+
pagination_info:
40+
description: The pagination information for paginated endpoints
41+
allOf:
42+
- $ref: '#/components/schemas/PaginationInfo'
3943
description: Successful response
4044
'503':
4145
content:
@@ -72,6 +76,10 @@ paths:
7276
type: array
7377
items:
7478
$ref: '#/components/schemas/ValidationError'
79+
pagination_info:
80+
description: The pagination information for paginated endpoints
81+
allOf:
82+
- $ref: '#/components/schemas/PaginationInfo'
7583
description: Successful response
7684
'422':
7785
content:
@@ -95,6 +103,61 @@ paths:
95103
$ref: '#/components/schemas/User'
96104
security:
97105
- ApiKeyAuth: []
106+
/v1/users/search:
107+
post:
108+
parameters: []
109+
responses:
110+
'200':
111+
content:
112+
application/json:
113+
schema:
114+
type: object
115+
properties:
116+
message:
117+
type: string
118+
description: The message to return
119+
data:
120+
type: array
121+
items:
122+
$ref: '#/components/schemas/User'
123+
status_code:
124+
type: integer
125+
description: The HTTP status code
126+
warnings:
127+
type: array
128+
items:
129+
$ref: '#/components/schemas/ValidationError'
130+
errors:
131+
type: array
132+
items:
133+
$ref: '#/components/schemas/ValidationError'
134+
pagination_info:
135+
description: The pagination information for paginated endpoints
136+
allOf:
137+
- $ref: '#/components/schemas/PaginationInfo'
138+
description: Successful response
139+
'422':
140+
content:
141+
application/json:
142+
schema:
143+
$ref: '#/components/schemas/ValidationError'
144+
description: Validation error
145+
'401':
146+
content:
147+
application/json:
148+
schema:
149+
$ref: '#/components/schemas/HTTPError'
150+
description: Authentication error
151+
tags:
152+
- User
153+
summary: User Search
154+
requestBody:
155+
content:
156+
application/json:
157+
schema:
158+
$ref: '#/components/schemas/UserSearch'
159+
security:
160+
- ApiKeyAuth: []
98161
/v1/users/{user_id}:
99162
get:
100163
parameters:
@@ -126,6 +189,10 @@ paths:
126189
type: array
127190
items:
128191
$ref: '#/components/schemas/ValidationError'
192+
pagination_info:
193+
description: The pagination information for paginated endpoints
194+
allOf:
195+
- $ref: '#/components/schemas/PaginationInfo'
129196
description: Successful response
130197
'401':
131198
content:
@@ -174,6 +241,10 @@ paths:
174241
type: array
175242
items:
176243
$ref: '#/components/schemas/ValidationError'
244+
pagination_info:
245+
description: The pagination information for paginated endpoints
246+
allOf:
247+
- $ref: '#/components/schemas/PaginationInfo'
177248
description: Successful response
178249
'422':
179250
content:
@@ -224,6 +295,34 @@ components:
224295
value:
225296
type: string
226297
description: The value that failed
298+
PaginationInfo:
299+
type: object
300+
properties:
301+
page_offset:
302+
type: integer
303+
description: The page number that was fetched
304+
example: 1
305+
page_size:
306+
type: integer
307+
description: The size of the page fetched
308+
example: 25
309+
total_records:
310+
type: integer
311+
description: The total number of records fetchable
312+
example: 42
313+
total_pages:
314+
type: integer
315+
description: The total number of pages that can be fetched
316+
example: 2
317+
order_by:
318+
type: string
319+
description: The field that the records were sorted by
320+
example: id
321+
sort_direction:
322+
description: The direction the records are sorted
323+
enum:
324+
- ascending
325+
- descending
227326
HTTPError:
228327
properties:
229328
detail:
@@ -260,9 +359,9 @@ components:
260359
description: The user's last name
261360
phone_number:
262361
type: string
362+
pattern: ^([0-9]|\*){3}\-([0-9]|\*){3}\-[0-9]{4}$
263363
description: The user's phone number
264364
example: 123-456-7890
265-
pattern: ^([0-9]|\*){3}\-([0-9]|\*){3}\-[0-9]{4}$
266365
date_of_birth:
267366
type: string
268367
format: date
@@ -289,6 +388,60 @@ components:
289388
- last_name
290389
- phone_number
291390
- roles
391+
UserSorting:
392+
type: object
393+
properties:
394+
order_by:
395+
type: string
396+
enum:
397+
- id
398+
- created_at
399+
- updated_at
400+
description: The field to sort the response by
401+
sort_direction:
402+
description: Whether to sort the response ascending or descending
403+
enum:
404+
- ascending
405+
- descending
406+
required:
407+
- order_by
408+
- sort_direction
409+
Pagination:
410+
type: object
411+
properties:
412+
page_size:
413+
type: integer
414+
minimum: 1
415+
description: The size of the page to fetch
416+
example: 25
417+
page_offset:
418+
type: integer
419+
minimum: 1
420+
description: The page number to fetch, starts counting from 1
421+
example: 1
422+
required:
423+
- page_offset
424+
- page_size
425+
UserSearch:
426+
type: object
427+
properties:
428+
phone_number:
429+
type: string
430+
pattern: ^([0-9]|\*){3}\-([0-9]|\*){3}\-[0-9]{4}$
431+
description: The user's phone number
432+
example: 123-456-7890
433+
is_active:
434+
type: boolean
435+
role_type:
436+
enum:
437+
- USER
438+
- ADMIN
439+
sorting:
440+
$ref: '#/components/schemas/UserSorting'
441+
paging:
442+
$ref: '#/components/schemas/Pagination'
443+
required:
444+
- paging
292445
UserUpdate:
293446
type: object
294447
properties:
@@ -307,9 +460,9 @@ components:
307460
description: The user's last name
308461
phone_number:
309462
type: string
463+
pattern: ^([0-9]|\*){3}\-([0-9]|\*){3}\-[0-9]{4}$
310464
description: The user's phone number
311465
example: 123-456-7890
312-
pattern: ^([0-9]|\*){3}\-([0-9]|\*){3}\-[0-9]{4}$
313466
date_of_birth:
314467
type: string
315468
format: date

app/src/api/healthcheck.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,11 @@ class HealthcheckSchema(request_schema.OrderedSchema):
2323
@healthcheck_blueprint.get("/health")
2424
@healthcheck_blueprint.output(HealthcheckSchema)
2525
@healthcheck_blueprint.doc(responses=[200, ServiceUnavailable.code])
26-
def health() -> Tuple[dict, int]:
26+
def health() -> Tuple[response.ApiResponse, int]:
2727
try:
2828
with flask_db.get_db(current_app).get_connection() as conn:
2929
assert conn.scalar(text("SELECT 1 AS healthy")) == 1
30-
return response.ApiResponse(message="Service healthy").asdict(), 200
30+
return response.ApiResponse(message="Service healthy"), 200
3131
except Exception:
3232
logger.exception("Connection to DB failure")
33-
return response.ApiResponse(message="Service unavailable").asdict(), ServiceUnavailable.code
33+
return response.ApiResponse(message="Service unavailable"), ServiceUnavailable.code

app/src/api/response.py

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import dataclasses
2-
from typing import Optional
2+
from typing import Any, Optional
33

4-
from src.api.schemas import response_schema
5-
from src.db.models.base import Base
4+
from src.pagination.pagination_models import PaginationInfo
65

76

87
@dataclasses.dataclass
@@ -33,16 +32,9 @@ class ApiResponse:
3332
"""Base response model for all API responses."""
3433

3534
message: str
36-
data: Optional[Base] = None
35+
data: Optional[Any] = None
3736
warnings: list[ValidationErrorDetail] = dataclasses.field(default_factory=list)
3837
errors: list[ValidationErrorDetail] = dataclasses.field(default_factory=list)
38+
status_code: int = 200
3939

40-
# This method is used to convert ApiResponse objects to a dictionary
41-
# This is necessary because APIFlask has a bug that causes an exception to be
42-
# thrown when returning objects from routes when BASE_RESPONSE_SCHEMA is set
43-
# (See https://github.com/apiflask/apiflask/issues/384)
44-
# Once that issue is fixed, this method can be removed and routes can simply
45-
# return ApiResponse objects directly and allow APIFlask to serealize the objects
46-
# to JSON automatically.
47-
def asdict(self) -> dict:
48-
return response_schema.ResponseSchema().dump(self)
40+
pagination_info: PaginationInfo | None = None

app/src/api/schemas/response_schema.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from apiflask import fields
22

33
from src.api.schemas import request_schema
4+
from src.pagination.pagination_schema import PaginationInfoSchema
45

56

67
class ValidationErrorSchema(request_schema.OrderedSchema):
@@ -15,5 +16,10 @@ class ResponseSchema(request_schema.OrderedSchema):
1516
message = fields.String(metadata={"description": "The message to return"})
1617
data = fields.Field(metadata={"description": "The REST resource object"}, dump_default={})
1718
status_code = fields.Integer(metadata={"description": "The HTTP status code"}, dump_default=200)
18-
warnings = fields.List(fields.Nested(ValidationErrorSchema), dump_default=[])
19-
errors = fields.List(fields.Nested(ValidationErrorSchema), dump_default=[])
19+
warnings = fields.List(fields.Nested(ValidationErrorSchema()), dump_default=[])
20+
errors = fields.List(fields.Nested(ValidationErrorSchema()), dump_default=[])
21+
22+
pagination_info = fields.Nested(
23+
PaginationInfoSchema(),
24+
metadata={"description": "The pagination information for paginated endpoints"},
25+
)

app/src/api/users/user_routes.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,13 @@
1919
@user_blueprint.output(user_schemas.UserSchema, status_code=201)
2020
@user_blueprint.auth_required(api_key_auth)
2121
@flask_db.with_db_session()
22-
def user_post(db_session: db.Session, user_params: users.CreateUserParams) -> dict:
22+
def user_post(db_session: db.Session, user_params: users.CreateUserParams) -> response.ApiResponse:
2323
"""
2424
POST /v1/users
2525
"""
2626
user = user_service.create_user(db_session, user_params)
2727
logger.info("Successfully inserted user", extra=get_user_log_params(user))
28-
return response.ApiResponse(message="Success", data=user).asdict()
28+
return response.ApiResponse(message="Success", data=user)
2929

3030

3131
@user_blueprint.patch("/v1/users/<uuid:user_id>")
@@ -38,20 +38,34 @@ def user_post(db_session: db.Session, user_params: users.CreateUserParams) -> di
3838
@flask_db.with_db_session()
3939
def user_patch(
4040
db_session: db.Session, user_id: str, patch_user_params: users.PatchUserParams
41-
) -> dict:
41+
) -> response.ApiResponse:
4242
user = user_service.patch_user(db_session, user_id, patch_user_params)
4343
logger.info("Successfully patched user", extra=get_user_log_params(user))
44-
return response.ApiResponse(message="Success", data=user).asdict()
44+
return response.ApiResponse(message="Success", data=user)
4545

4646

4747
@user_blueprint.get("/v1/users/<uuid:user_id>")
4848
@user_blueprint.output(user_schemas.UserSchema)
4949
@user_blueprint.auth_required(api_key_auth)
5050
@flask_db.with_db_session()
51-
def user_get(db_session: db.Session, user_id: str) -> dict:
51+
def user_get(db_session: db.Session, user_id: str) -> response.ApiResponse:
5252
user = user_service.get_user(db_session, user_id)
5353
logger.info("Successfully fetched user", extra=get_user_log_params(user))
54-
return response.ApiResponse(message="Success", data=user).asdict()
54+
return response.ApiResponse(message="Success", data=user)
55+
56+
57+
@user_blueprint.post("/v1/users/search")
58+
@user_blueprint.input(user_schemas.UserSearchSchema, arg_name="search_params")
59+
# many=True allows us to return a list of user objects
60+
@user_blueprint.output(user_schemas.UserSchema(many=True))
61+
@user_blueprint.auth_required(api_key_auth)
62+
@flask_db.with_db_session()
63+
def user_search(db_session: db.Session, search_params: dict) -> response.ApiResponse:
64+
user_result, pagination_info = user_service.search_user(db_session, search_params)
65+
logger.info("Successfully searched users")
66+
return response.ApiResponse(
67+
message="Success", data=user_result, pagination_info=pagination_info
68+
)
5569

5670

5771
def get_user_log_params(user: User) -> dict[str, Any]:

app/src/api/users/user_schemas.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1-
from apiflask import fields
1+
from apiflask import fields, validators
22
from marshmallow import fields as marshmallow_fields
33

44
from src.api.schemas import request_schema
55
from src.db.models import user_models
6+
from src.pagination.pagination_schema import PaginationSchema, generate_sorting_schema
7+
8+
PHONE_NUMBER_VALIDATOR = validators.Regexp(r"^([0-9]|\*){3}\-([0-9]|\*){3}\-[0-9]{4}$")
69

710

811
class RoleSchema(request_schema.OrderedSchema):
@@ -23,10 +26,10 @@ class UserSchema(request_schema.OrderedSchema):
2326
last_name = fields.String(metadata={"description": "The user's last name"}, required=True)
2427
phone_number = fields.String(
2528
required=True,
29+
validate=[PHONE_NUMBER_VALIDATOR],
2630
metadata={
2731
"description": "The user's phone number",
2832
"example": "123-456-7890",
29-
"pattern": r"^([0-9]|\*){3}\-([0-9]|\*){3}\-[0-9]{4}$",
3033
},
3134
)
3235
date_of_birth = fields.Date(
@@ -37,8 +40,26 @@ class UserSchema(request_schema.OrderedSchema):
3740
metadata={"description": "Whether the user is active"},
3841
required=True,
3942
)
40-
roles = fields.List(fields.Nested(RoleSchema), required=True)
43+
roles = fields.List(fields.Nested(RoleSchema()), required=True)
4144

4245
# Output only fields in addition to id field
4346
created_at = fields.DateTime(dump_only=True)
4447
updated_at = fields.DateTime(dump_only=True)
48+
49+
50+
class UserSearchSchema(request_schema.OrderedSchema):
51+
# Fields that you can search for users by, only includes a subset of user fields
52+
phone_number = fields.String(
53+
validate=[PHONE_NUMBER_VALIDATOR],
54+
metadata={
55+
"description": "The user's phone number",
56+
"example": "123-456-7890",
57+
},
58+
)
59+
60+
is_active = fields.Boolean()
61+
62+
role_type = fields.Enum(user_models.RoleType, by_value=True)
63+
64+
sorting = fields.Nested(generate_sorting_schema("UserSortingSchema")())
65+
paging = fields.Nested(PaginationSchema(), required=True)

app/src/pagination/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)