Skip to content

Commit efce7fc

Browse files
Merge pull request #28 from piyush-jaiswal/feature/cursor-based-pagination
Feature/cursor based pagination
2 parents 25d426f + 8edc586 commit efce7fc

File tree

8 files changed

+65
-32
lines changed

8 files changed

+65
-32
lines changed

README.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ Ability to create, read, update, and delete products, categories and subcategori
1010
<br></br>
1111
Fetching a product fetches the details of categories and subcategories it belongs to. Provides the ability to search for products by name, category and subcategories.
1212
<br></br>
13-
Paginates result when products are fetched by categories or subcategories.
13+
Paginates result using cursor based pagination when products are fetched by categories, subcategories or themselves.
1414

1515
Deployed as a vercel function with Postgres: [ecommerce-rest-api-five.vercel.app](https://ecommerce-rest-api-five.vercel.app)
1616
<br> Documented with Swagger UI.
@@ -64,9 +64,10 @@ Test the API using Swagger UI (`/` route), Postman, cURL or your preferred HTTP
6464

6565
#### Fetch products using name, category, subcategory
6666
- [GET] `/products?name=<name: string>` - Get product with name: `name` <br/><br/>
67-
- [GET] `/subcategories/<subcategory_id: int>/products?page=<page_no>` - Get product with within subcategory `subcategory`. Returns `page_no` of the paginated results. <br/><br/>
68-
- [GET] `/categories/<category_id: int>/products` - Get product with within category `category`. Returns first page of the paginated results. <br/><br/>
69-
- [GET] `/categories/<category_id: int>/products?page=<page_no>` - Get product with within category `category`. Returns `page_no` of the paginated results. <br/><br/>
67+
- [GET] `/subcategories/<subcategory_id: int>/products` - Get first page of products within subcategory `subcategory_id`. <br/><br/>
68+
- [GET] `/subcategories/<subcategory_id: int>/products?cursor=<cursor: str>` - Get products paginated using cursor within subcategory `subcategory_id`. Next and previous page `cursors` provided in responses. <br/><br/>
69+
- [GET] `/categories/<category_id: int>/products` - Get first page of products within category `category_id`. <br/><br/>
70+
- [GET] `/categories/<category_id: int>/products?cursor=<cursor: str>` - Get products paginated using cursor within category `category_id`. Next and previous page `cursors` provided in responses. <br/><br/>
7071

7172

7273
#### Authorization
@@ -150,7 +151,8 @@ Test the API using Swagger UI (`/` route), Postman, cURL or your preferred HTTP
150151
<br/>
151152

152153
#### Product
153-
- [GET] `/products` - Get all products
154+
- [GET] `/products` - Get first page of products
155+
- [GET] `/products?cursor=<cursor: str>` - Get products paginated using cursor. Next and previous page `cursors` provided in responses.
154156
- [GET] `/products/(int: product_id)` - Get product with product_id
155157
- [GET] `/products/(int: product_id)/subcategories` - Get subcategories related to product_id
156158
- [DELETE] `/products/(int: product_id)` (Protected) - Delete product with product_id

app/routes/category.py

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from flask_jwt_extended import jwt_required
33
from flask_smorest import Blueprint, abort
44
from psycopg2.errors import UniqueViolation
5+
from sqlakeyset import get_page
56
from sqlalchemy import UniqueConstraint, exists
67
from sqlalchemy.exc import IntegrityError
78

@@ -154,17 +155,14 @@ class CategoryProducts(MethodView):
154155
@bp.doc(summary="Get Products within a Category")
155156
@bp.arguments(PaginationArgs, location="query", as_kwargs=True)
156157
@bp.response(200, ProductsOut)
157-
def get(self, id, page):
158+
def get(self, id, cursor):
158159
category_exists = db.session.query(exists().where(Category.id == id)).scalar()
159160
if not category_exists:
160161
abort(404)
161162

162-
products = (
163-
Product.query.filter(
164-
Product.subcategories.any(Subcategory.categories.any(id=id))
165-
)
166-
.order_by(Product.id.asc())
167-
.paginate(page=page, per_page=CategoryProducts._PER_PAGE, error_out=False)
168-
)
163+
products = Product.query.filter(
164+
Product.subcategories.any(Subcategory.categories.any(id=id))
165+
).order_by(Product.id.asc())
166+
page = get_page(products, per_page=CategoryProducts._PER_PAGE, page=cursor)
169167

170-
return {"products": products}
168+
return { "products": page, "cursor": page.paging }

app/routes/product.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from flask_jwt_extended import jwt_required
33
from flask_smorest import Blueprint, abort
44
from psycopg2.errors import UniqueViolation
5+
from sqlakeyset import get_page
56
from sqlalchemy import UniqueConstraint
67
from sqlalchemy.exc import IntegrityError
78

@@ -51,15 +52,14 @@ def _get_by_name(self, name):
5152
@bp.arguments(NameArgs, location="query", as_kwargs=True)
5253
@bp.arguments(PaginationArgs, location="query", as_kwargs=True)
5354
@bp.response(200, ProductsOut)
54-
def get(self, name, page):
55+
def get(self, name, cursor):
5556
if name is not None:
5657
products = self._get_by_name(name)
58+
return {"products": products}
5759
else:
58-
products = Product.query.order_by(Product.id.asc()).paginate(
59-
page=page, per_page=ProductCollection._PER_PAGE, error_out=False
60-
)
61-
62-
return {"products": products}
60+
products = Product.query.order_by(Product.id.asc())
61+
page = get_page(products, per_page=ProductCollection._PER_PAGE, page=cursor)
62+
return {"products": page, "cursor": page.paging}
6363

6464
@jwt_required()
6565
@bp.doc(summary="Create Product", security=[{"access_token": []}])

app/routes/subcategory.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from flask_jwt_extended import jwt_required
33
from flask_smorest import Blueprint, abort
44
from psycopg2.errors import UniqueViolation
5+
from sqlakeyset import get_page
56
from sqlalchemy import UniqueConstraint
67
from sqlalchemy.exc import IntegrityError
78

@@ -169,11 +170,9 @@ class SubcategoryProducts(MethodView):
169170
@bp.doc(summary="Get Products within a Subcategory")
170171
@bp.arguments(PaginationArgs, location="query", as_kwargs=True)
171172
@bp.response(200, ProductsOut)
172-
def get(self, id, page):
173+
def get(self, id, cursor):
173174
subcategory = Subcategory.query.get_or_404(id)
175+
products = subcategory.products.order_by(Product.id.asc())
176+
page = get_page(products, per_page=SubcategoryProducts._PER_PAGE, page=cursor)
174177

175-
products = subcategory.products.order_by(Product.id.asc()).paginate(
176-
page=page, per_page=SubcategoryProducts._PER_PAGE, error_out=False
177-
)
178-
179-
return {"products": products}
178+
return {"products": page, "cursor": page.paging}

app/schemas.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,38 @@
1+
import base64
2+
13
from marshmallow import Schema, ValidationError, fields, pre_load, validate, validates
24
from marshmallow_sqlalchemy import SQLAlchemyAutoSchema, SQLAlchemySchema, auto_field
5+
from sqlakeyset import BadBookmark, unserialize_bookmark
36

47
from app.models import Category, Product, Subcategory, User
58

69

10+
class Cursor(fields.Field[dict]):
11+
def _serialize(self, paging, attr, obj, **kwargs):
12+
def encode(s):
13+
bytes = s.encode("utf-8")
14+
return base64.urlsafe_b64encode(bytes).decode("utf-8")
15+
16+
if paging is None:
17+
return None
18+
19+
return {
20+
"next": encode(paging.bookmark_next) if paging.has_next else None,
21+
"prev": encode(paging.bookmark_previous) if paging.has_previous else None,
22+
}
23+
24+
def _deserialize(self, cursor, attr, data, **kwargs):
25+
if cursor is None:
26+
return None
27+
28+
try:
29+
decoded_bytes = base64.urlsafe_b64decode(cursor.encode("utf-8"))
30+
marker_serialized = decoded_bytes.decode("utf-8")
31+
return unserialize_bookmark(marker_serialized)
32+
except (TypeError, ValueError, KeyError, BadBookmark) as ex:
33+
raise ValidationError("Invalid cursor") from ex
34+
35+
736
class CategoryOut(SQLAlchemyAutoSchema):
837
class Meta:
938
model = Category
@@ -70,6 +99,7 @@ class Meta:
7099

71100
class ProductsOut(Schema):
72101
products = fields.List(fields.Nested(ProductOut))
102+
cursor = Cursor(required=False) # require false for getting product by name
73103

74104

75105
class ProductIn(SQLAlchemySchema):
@@ -100,7 +130,7 @@ class NameArgs(Schema):
100130

101131

102132
class PaginationArgs(Schema):
103-
page = fields.Int(load_default=1)
133+
cursor = Cursor(load_default=None)
104134

105135

106136
class AuthIn(SQLAlchemySchema):

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ email-normalize==0.2.1
99
Flask-JWT-Extended==4.7.1
1010
flask-smorest==0.46.2
1111
marshmallow-sqlalchemy==1.4.2
12+
sqlakeyset==2.0.1746777265

tests/test_product.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,8 @@ def test_products_pagination(self, create_product):
222222
assert len(data1["products"]) == 10
223223

224224
# Page 2
225-
resp2 = self.client.get("/products?page=2")
225+
next_cursor = data1["cursor"]["next"]
226+
resp2 = self.client.get(f"/products?cursor={next_cursor}")
226227
assert resp2.status_code == 200
227228
data2 = resp2.get_json()
228229
assert "products" in data2

tests/test_relationships.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -218,8 +218,9 @@ def test_get_category_products_populated_with_pagination(self, create_category,
218218
product_resp = create_product(f"P{index}", "desc", subcategories=[subcategory["id"]])
219219
product_ids.add(product_resp.get_json().get("id"))
220220

221-
page1 = self.client.get(f"/categories/{category['id']}/products?page=1").get_json()
222-
page2 = self.client.get(f"/categories/{category['id']}/products?page=2").get_json()
221+
page1 = self.client.get(f"/categories/{category['id']}/products").get_json()
222+
next_cursor = page1["cursor"]["next"]
223+
page2 = self.client.get(f"/categories/{category['id']}/products?cursor={next_cursor}").get_json()
223224
assert len(page1["products"]) == 10
224225
assert len(page2["products"]) == 2
225226

@@ -252,8 +253,9 @@ def test_get_subcategory_products_populated_with_pagination(self, create_subcate
252253
product_resp = create_product(f"SP{index}", "desc", subcategories=[subcategory["id"]])
253254
product_ids.add(product_resp.get_json().get("id"))
254255

255-
page1 = self.client.get(f"/subcategories/{subcategory['id']}/products?page=1").get_json()
256-
page2 = self.client.get(f"/subcategories/{subcategory['id']}/products?page=2").get_json()
256+
page1 = self.client.get(f"/subcategories/{subcategory['id']}/products").get_json()
257+
next_cursor = page1["cursor"]["next"]
258+
page2 = self.client.get(f"/subcategories/{subcategory['id']}/products?cursor={next_cursor}").get_json()
257259
assert len(page1["products"]) == 10
258260
assert len(page2["products"]) == 1
259261

0 commit comments

Comments
 (0)