diff --git a/README.md b/README.md index f55e75b..b93b6c4 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Ability to create, read, update, and delete products, categories and subcategori

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.

-Paginates result when products are fetched by categories or subcategories. +Paginates result using cursor based pagination when products are fetched by categories, subcategories or themselves. Deployed as a vercel function with Postgres: [ecommerce-rest-api-five.vercel.app](https://ecommerce-rest-api-five.vercel.app)
Documented with Swagger UI. @@ -64,9 +64,10 @@ Test the API using Swagger UI (`/` route), Postman, cURL or your preferred HTTP #### Fetch products using name, category, subcategory - [GET] `/products?name=` - Get product with name: `name`

-- [GET] `/subcategories//products?page=` - Get product with within subcategory `subcategory`. Returns `page_no` of the paginated results.

-- [GET] `/categories//products` - Get product with within category `category`. Returns first page of the paginated results.

-- [GET] `/categories//products?page=` - Get product with within category `category`. Returns `page_no` of the paginated results.

+- [GET] `/subcategories//products` - Get first page of products within subcategory `subcategory_id`.

+- [GET] `/subcategories//products?cursor=` - Get products paginated using cursor within subcategory `subcategory_id`. Next and previous page `cursors` provided in responses.

+- [GET] `/categories//products` - Get first page of products within category `category_id`.

+- [GET] `/categories//products?cursor=` - Get products paginated using cursor within category `category_id`. Next and previous page `cursors` provided in responses.

#### Authorization @@ -150,7 +151,8 @@ Test the API using Swagger UI (`/` route), Postman, cURL or your preferred HTTP
#### Product -- [GET] `/products` - Get all products +- [GET] `/products` - Get first page of products +- [GET] `/products?cursor=` - Get products paginated using cursor. Next and previous page `cursors` provided in responses. - [GET] `/products/(int: product_id)` - Get product with product_id - [GET] `/products/(int: product_id)/subcategories` - Get subcategories related to product_id - [DELETE] `/products/(int: product_id)` (Protected) - Delete product with product_id diff --git a/app/routes/category.py b/app/routes/category.py index 965037f..6622b39 100644 --- a/app/routes/category.py +++ b/app/routes/category.py @@ -2,6 +2,7 @@ from flask_jwt_extended import jwt_required from flask_smorest import Blueprint, abort from psycopg2.errors import UniqueViolation +from sqlakeyset import get_page from sqlalchemy import UniqueConstraint, exists from sqlalchemy.exc import IntegrityError @@ -154,17 +155,14 @@ class CategoryProducts(MethodView): @bp.doc(summary="Get Products within a Category") @bp.arguments(PaginationArgs, location="query", as_kwargs=True) @bp.response(200, ProductsOut) - def get(self, id, page): + def get(self, id, cursor): category_exists = db.session.query(exists().where(Category.id == id)).scalar() if not category_exists: abort(404) - products = ( - Product.query.filter( - Product.subcategories.any(Subcategory.categories.any(id=id)) - ) - .order_by(Product.id.asc()) - .paginate(page=page, per_page=CategoryProducts._PER_PAGE, error_out=False) - ) + products = Product.query.filter( + Product.subcategories.any(Subcategory.categories.any(id=id)) + ).order_by(Product.id.asc()) + page = get_page(products, per_page=CategoryProducts._PER_PAGE, page=cursor) - return {"products": products} + return { "products": page, "cursor": page.paging } diff --git a/app/routes/product.py b/app/routes/product.py index 48a35af..e923dd2 100644 --- a/app/routes/product.py +++ b/app/routes/product.py @@ -2,6 +2,7 @@ from flask_jwt_extended import jwt_required from flask_smorest import Blueprint, abort from psycopg2.errors import UniqueViolation +from sqlakeyset import get_page from sqlalchemy import UniqueConstraint from sqlalchemy.exc import IntegrityError @@ -51,15 +52,14 @@ def _get_by_name(self, name): @bp.arguments(NameArgs, location="query", as_kwargs=True) @bp.arguments(PaginationArgs, location="query", as_kwargs=True) @bp.response(200, ProductsOut) - def get(self, name, page): + def get(self, name, cursor): if name is not None: products = self._get_by_name(name) + return {"products": products} else: - products = Product.query.order_by(Product.id.asc()).paginate( - page=page, per_page=ProductCollection._PER_PAGE, error_out=False - ) - - return {"products": products} + products = Product.query.order_by(Product.id.asc()) + page = get_page(products, per_page=ProductCollection._PER_PAGE, page=cursor) + return {"products": page, "cursor": page.paging} @jwt_required() @bp.doc(summary="Create Product", security=[{"access_token": []}]) diff --git a/app/routes/subcategory.py b/app/routes/subcategory.py index 371b632..ba684fa 100644 --- a/app/routes/subcategory.py +++ b/app/routes/subcategory.py @@ -2,6 +2,7 @@ from flask_jwt_extended import jwt_required from flask_smorest import Blueprint, abort from psycopg2.errors import UniqueViolation +from sqlakeyset import get_page from sqlalchemy import UniqueConstraint from sqlalchemy.exc import IntegrityError @@ -169,11 +170,9 @@ class SubcategoryProducts(MethodView): @bp.doc(summary="Get Products within a Subcategory") @bp.arguments(PaginationArgs, location="query", as_kwargs=True) @bp.response(200, ProductsOut) - def get(self, id, page): + def get(self, id, cursor): subcategory = Subcategory.query.get_or_404(id) + products = subcategory.products.order_by(Product.id.asc()) + page = get_page(products, per_page=SubcategoryProducts._PER_PAGE, page=cursor) - products = subcategory.products.order_by(Product.id.asc()).paginate( - page=page, per_page=SubcategoryProducts._PER_PAGE, error_out=False - ) - - return {"products": products} + return {"products": page, "cursor": page.paging} diff --git a/app/schemas.py b/app/schemas.py index d4cda6c..faa83e5 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -1,9 +1,38 @@ +import base64 + from marshmallow import Schema, ValidationError, fields, pre_load, validate, validates from marshmallow_sqlalchemy import SQLAlchemyAutoSchema, SQLAlchemySchema, auto_field +from sqlakeyset import BadBookmark, unserialize_bookmark from app.models import Category, Product, Subcategory, User +class Cursor(fields.Field[dict]): + def _serialize(self, paging, attr, obj, **kwargs): + def encode(s): + bytes = s.encode("utf-8") + return base64.urlsafe_b64encode(bytes).decode("utf-8") + + if paging is None: + return None + + return { + "next": encode(paging.bookmark_next) if paging.has_next else None, + "prev": encode(paging.bookmark_previous) if paging.has_previous else None, + } + + def _deserialize(self, cursor, attr, data, **kwargs): + if cursor is None: + return None + + try: + decoded_bytes = base64.urlsafe_b64decode(cursor.encode("utf-8")) + marker_serialized = decoded_bytes.decode("utf-8") + return unserialize_bookmark(marker_serialized) + except (TypeError, ValueError, KeyError, BadBookmark) as ex: + raise ValidationError("Invalid cursor") from ex + + class CategoryOut(SQLAlchemyAutoSchema): class Meta: model = Category @@ -70,6 +99,7 @@ class Meta: class ProductsOut(Schema): products = fields.List(fields.Nested(ProductOut)) + cursor = Cursor(required=False) # require false for getting product by name class ProductIn(SQLAlchemySchema): @@ -100,7 +130,7 @@ class NameArgs(Schema): class PaginationArgs(Schema): - page = fields.Int(load_default=1) + cursor = Cursor(load_default=None) class AuthIn(SQLAlchemySchema): diff --git a/requirements.txt b/requirements.txt index b7a4dae..1ead7f0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,3 +9,4 @@ email-normalize==0.2.1 Flask-JWT-Extended==4.7.1 flask-smorest==0.46.2 marshmallow-sqlalchemy==1.4.2 +sqlakeyset==2.0.1746777265 diff --git a/tests/test_product.py b/tests/test_product.py index 541854e..52b7528 100644 --- a/tests/test_product.py +++ b/tests/test_product.py @@ -222,7 +222,8 @@ def test_products_pagination(self, create_product): assert len(data1["products"]) == 10 # Page 2 - resp2 = self.client.get("/products?page=2") + next_cursor = data1["cursor"]["next"] + resp2 = self.client.get(f"/products?cursor={next_cursor}") assert resp2.status_code == 200 data2 = resp2.get_json() assert "products" in data2 diff --git a/tests/test_relationships.py b/tests/test_relationships.py index 855b512..173fd45 100644 --- a/tests/test_relationships.py +++ b/tests/test_relationships.py @@ -218,8 +218,9 @@ def test_get_category_products_populated_with_pagination(self, create_category, product_resp = create_product(f"P{index}", "desc", subcategories=[subcategory["id"]]) product_ids.add(product_resp.get_json().get("id")) - page1 = self.client.get(f"/categories/{category['id']}/products?page=1").get_json() - page2 = self.client.get(f"/categories/{category['id']}/products?page=2").get_json() + page1 = self.client.get(f"/categories/{category['id']}/products").get_json() + next_cursor = page1["cursor"]["next"] + page2 = self.client.get(f"/categories/{category['id']}/products?cursor={next_cursor}").get_json() assert len(page1["products"]) == 10 assert len(page2["products"]) == 2 @@ -252,8 +253,9 @@ def test_get_subcategory_products_populated_with_pagination(self, create_subcate product_resp = create_product(f"SP{index}", "desc", subcategories=[subcategory["id"]]) product_ids.add(product_resp.get_json().get("id")) - page1 = self.client.get(f"/subcategories/{subcategory['id']}/products?page=1").get_json() - page2 = self.client.get(f"/subcategories/{subcategory['id']}/products?page=2").get_json() + page1 = self.client.get(f"/subcategories/{subcategory['id']}/products").get_json() + next_cursor = page1["cursor"]["next"] + page2 = self.client.get(f"/subcategories/{subcategory['id']}/products?cursor={next_cursor}").get_json() assert len(page1["products"]) == 10 assert len(page2["products"]) == 1