diff --git a/README.md b/README.md index b9707bc..8599e4a 100644 --- a/README.md +++ b/README.md @@ -65,8 +65,8 @@ Test the API using Swagger UI (`/` route), Postman, cURL or your preferred HTTP #### Fetch products using name, category, subcategory - [GET] `/product/` - Get product with name: `name`

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

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

-- [GET] `/category//products?page=` - Get product with within category `category`. 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.

#### Authorization @@ -100,11 +100,11 @@ Test the API using Swagger UI (`/` route), Postman, cURL or your preferred HTTP #### Category - [GET] `/categories` - Get all categories -- [GET] `/category/(int: category_id)` - Get category with category_id -- [GET] `/category/(int: category_id)/subcategories` - Get subcategories within a category_id. -- [DELETE] `/category/(int: category_id)` (Protected) - Delete category with category_id +- [GET] `/categories/(int: category_id)` - Get category with category_id +- [GET] `/categories/(int: category_id)/subcategories` - Get subcategories within a category_id. +- [DELETE] `/categories/(int: category_id)` (Protected) - Delete category with category_id -- [POST] `/category/create` (Protected) - Create a new category +- [POST] `/categories` (Protected) - Create a new category ``` { "name": "name", @@ -112,7 +112,7 @@ Test the API using Swagger UI (`/` route), Postman, cURL or your preferred HTTP } ``` -- [PUT] `/category/(int: category_id)/update` (Protected) - Update category with category_id +- [PUT] `/categories/(int: category_id)` (Protected) - Update category with category_id ``` { "name": "name", diff --git a/app/__init__.py b/app/__init__.py index 606ccc9..4580a26 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -8,18 +8,32 @@ from dotenv import load_dotenv from flasgger import Swagger from sqlalchemy import MetaData +from flask_smorest import Api + + +def register_blueprints(): + from app.migrated_routes.category import bp as category_bp + api.register_blueprint(category_bp, url_prefix="/categories") app = Flask(__name__) load_dotenv() + +# sqlalchemy app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv("SQLALCHEMY_DATABASE_URI") app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +# jwt app.config["JWT_SECRET_KEY"] = os.getenv("JWT_SECRET_KEY") app.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(hours=3) app.config["JWT_REFRESH_TOKEN_EXPIRES"] = timedelta(days=3) +# flask-smorest +app.config["API_TITLE"] = "Ecommerce REST API" +app.config["API_VERSION"] = "v1" +app.config["OPENAPI_VERSION"] = "3.0.2" + # PostgreSQL-compatible naming convention (to follow the naming convention already used in the DB) # https://stackoverflow.com/questions/4107915/postgresql-default-constraint-names naming_convention = { @@ -33,6 +47,9 @@ db = SQLAlchemy(app, metadata=metadata) migrate = Migrate(app, db) jwt = JWTManager(app) +api = Api(app) + +register_blueprints() @jwt.expired_token_loader diff --git a/app/migrated_routes/__init__.py b/app/migrated_routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/migrated_routes/category.py b/app/migrated_routes/category.py new file mode 100644 index 0000000..0ee3024 --- /dev/null +++ b/app/migrated_routes/category.py @@ -0,0 +1,292 @@ +from flask.views import MethodView +from flask_jwt_extended import jwt_required +from flask_smorest import Blueprint, abort +from sqlalchemy import exists + +from app import db +from app.models import ( + Category, + Product, + Subcategory, + category_subcategory, + subcategory_product, +) +from app.schemas import ( + CategoriesOut, + CategoryIn, + CategoryOut, + PaginationArgs, + ProductsOut, + SubcategoriesOut, +) + +bp = Blueprint("category", __name__) + + +@bp.route("") +class CategoryCollection(MethodView): + init_every_request = False + + @bp.response(200, CategoriesOut) + def get(self): + """ + Get All Categories + --- + tags: + - Category + description: Get all categories. + responses: + 200: + description: A list of categories. + """ + return {"categories": Category.query.all()} + + @jwt_required() + @bp.arguments(CategoryIn) + @bp.response(201, CategoryOut) + def post(self, data): + """ + Create Category + --- + tags: + - Category + description: Create a new category. + security: + - access_token: [] + requestBody: + required: true + description: name - Name of the category
subcategories - Array of subcategory ids (optional) + content: + application/json: + schema: + type: object + required: + - name + properties: + name: + type: string + subcategories: + type: array + items: + type: integer + responses: + 201: + description: Category created successfully. + 400: + description: Invalid input. + 401: + description: Token expired, missing or invalid. + 500: + description: Error occurred. + """ + category = Category(name=data["name"]) + + if sc_ids := data.get("subcategories"): + subcategories = Subcategory.query.filter(Subcategory.id.in_(sc_ids)).all() + if len(subcategories) != len(sc_ids): + abort(422, message="One or more subcategories not present") + + category.subcategories = subcategories + + db.session.add(category) + db.session.commit() + + return category + + +@bp.route("/") +class CategoryById(MethodView): + init_every_request = False + + def _get(self, id): + return Category.query.get_or_404(id) + + @bp.response(200, CategoryOut) + def get(self, id): + """ + Get Category + --- + tags: + - Category + description: Get a category by ID. + parameters: + - in: path + name: id + required: true + type: integer + description: Category ID + responses: + 200: + description: Category retrieved successfully. + 404: + description: Category not found. + """ + return self._get(id) + + @jwt_required() + @bp.arguments(CategoryIn(partial=("name",))) + @bp.response(200, CategoryOut) + def put(self, data, id): + """ + Update Category + --- + tags: + - Category + description: Update an existing category. + security: + - access_token: [] + parameters: + - in: path + name: id + required: true + type: integer + description: Category ID + requestBody: + required: true + description: name - Name of the category (optional)
subcategories - Array of subcategory ids + content: + application/json: + schema: + type: object + properties: + name: + type: string + subcategories: + type: array + items: + type: integer + responses: + 201: + description: Category updated successfully. + 400: + description: Invalid input. + 404: + description: Category not found. + 500: + description: Error occurred. + """ + category = self._get(id) + if name := data.get("name"): + category.name = name + + if sc_ids := data.get("subcategories"): + subcategories = Subcategory.query.filter(Subcategory.id.in_(sc_ids)).all() + if len(subcategories) != len(sc_ids): + abort(422, message="One or more subcategories not present") + + category.subcategories.extend(subcategories) + + db.session.commit() + return category + + @jwt_required() + @bp.response(204) + def delete(self, id): + """ + Delete Category + --- + tags: + - Category + description: Delete a category by ID. + security: + - access_token: [] + parameters: + - in: path + name: id + required: true + type: integer + description: Category ID + responses: + 200: + description: Category deleted successfully. + 404: + description: Category not found. + 500: + description: Error occurred. + """ + category = self._get(id) + db.session.delete(category) + db.session.commit() + + +@bp.route("//subcategories") +class CategorySubcategories(MethodView): + init_every_request = False + + @bp.response(200, SubcategoriesOut) + def get(self, id): + """ + Get Subcategories within a Category. + --- + tags: + - Category + description: Get Subcategories within a Category. + parameters: + - in: path + name: id + required: true + type: integer + description: Category ID + responses: + 200: + description: Subcategories retrieved successfully. + 404: + description: Category not found. + 500: + description: Error occurred. + """ + category = Category.query.get_or_404(id) + return {"subcategories": category.subcategories} + + +@bp.route("//products") +class CategoryProducts(MethodView): + init_every_request = False + _PER_PAGE = 10 + + @bp.arguments(PaginationArgs, location="query", as_kwargs=True) + @bp.response(200, ProductsOut) + def get(self, id, page): + """ + Get Products within a Category. + --- + tags: + - Category + description: Get Products for a Category. + parameters: + - in: path + name: id + required: true + type: integer + description: Category ID + - in: query + name: page + type: integer + default: 1 + description: Page number + responses: + 200: + description: Products retrieved successfully. + 404: + description: Category not found. + 500: + description: Error occurred. + """ + category_exists = db.session.query(exists().where(Category.id == id)).scalar() + if not category_exists: + abort(404) + + products = ( + Product.query.join(subcategory_product) + .join( + category_subcategory, + onclause=subcategory_product.c.subcategory_id + == category_subcategory.c.subcategory_id, + ) + .filter(category_subcategory.c.category_id == id) + .distinct() + .order_by(Product.id.asc()) + .paginate(page=page, per_page=CategoryProducts._PER_PAGE, error_out=False) + ) + + return {"products": products} diff --git a/app/routes.py b/app/routes.py index 0858b11..1356f6e 100644 --- a/app/routes.py +++ b/app/routes.py @@ -4,7 +4,7 @@ from email_validator import EmailNotValidError from app import app, db -from app.models import Category, Subcategory, Product, User, category_subcategory, subcategory_product +from app.models import Category, Subcategory, Product, User @app.route('/auth/register', methods=['POST']) @@ -135,283 +135,6 @@ def refresh(): return jsonify(access_token=access_token), 200 -@app.route('/category/create', methods=['POST']) -@jwt_required() -def create_category(): - """ - Create Category - --- - tags: - - Category - description: Create a new category. - security: - - access_token: [] - requestBody: - required: true - description: name - Name of the category
subcategories - Array of subcategory ids (optional) - content: - application/json: - schema: - type: object - required: - - name - properties: - name: - type: string - subcategories: - type: array - items: - type: integer - responses: - 201: - description: Category created successfully. - 400: - description: Invalid input. - 401: - description: Token expired, missing or invalid. - 500: - description: Error occurred. - """ - if not request.json: - abort(400) - - try: - category = Category(name=request.json.get('name')) - sc_ids = request.json.get('subcategories') - if sc_ids is not None: - subcategories = Subcategory.query.filter(Subcategory.id.in_(sc_ids)) - category.subcategories.extend(subcategories) - db.session.add(category) - db.session.commit() - return jsonify(category.to_json()), 201 - except: - return "Error occured", 500 - - -@app.route('/category/', methods=['GET']) -def get_category(c_id): - """ - Get Category - --- - tags: - - Category - description: Get a category by ID. - parameters: - - in: path - name: c_id - required: true - type: integer - description: Category ID - responses: - 200: - description: Category retrieved successfully. - 404: - description: Category not found. - """ - category = Category.query.get(c_id) - if category is None: - abort(404) - return jsonify(category.to_json()), 200 - - -@app.route('/category//update', methods=['PUT']) -@jwt_required() -def update_category(c_id): - """ - Update Category - --- - tags: - - Category - description: Update an existing category. - security: - - access_token: [] - parameters: - - in: path - name: c_id - required: true - type: integer - description: Category ID - requestBody: - required: true - description: name - Name of the category (optional)
subcategories - Array of subcategory ids - content: - application/json: - schema: - type: object - properties: - name: - type: string - subcategories: - type: array - items: - type: integer - responses: - 201: - description: Category updated successfully. - 400: - description: Invalid input. - 404: - description: Category not found. - 500: - description: Error occurred. - """ - if not request.json: - abort(400) - - category = Category.query.get(c_id) - if category is None: - abort(404) - try: - name = request.json.get('name') - sc_ids = request.json.get('subcategories') - if name is not None: - category.name = request.json.get('name') - if sc_ids is not None: - subcategories = Subcategory.query.filter(Subcategory.id.in_(sc_ids)) - category.subcategories.extend(subcategories) - db.session.commit() - return jsonify(category.to_json()), 201 - except: - return "Error occured", 500 - - -@app.route("/category/", methods=["DELETE"]) -@jwt_required() -def delete_category(c_id): - """ - Delete Category - --- - tags: - - Category - description: Delete a category by ID. - security: - - access_token: [] - parameters: - - in: path - name: c_id - required: true - type: integer - description: Category ID - responses: - 200: - description: Category deleted successfully. - 404: - description: Category not found. - 500: - description: Error occurred. - """ - category = Category.query.get(c_id) - if category is None: - abort(404) - try: - db.session.delete(category) - db.session.commit() - return jsonify({'result': True}), 200 - except: - return "Error occured", 500 - - -@app.route('/categories', methods=['GET']) -def get_all_categories(): - """ - Get All Categories - --- - tags: - - Category - description: Get all categories. - responses: - 200: - description: A list of categories. - """ - categories = Category.query.order_by(Category.name).all() - return jsonify({"categories": [category.to_json() for category in categories]}), 200 - - -@app.route('/category//subcategories', methods=['GET']) -def get_category_subcategories(c_id): - """ - Get Subcategories within a Category. - --- - tags: - - Category - description: Get Subcategories within a Category. - parameters: - - in: path - name: c_id - required: true - type: integer - description: Category ID - responses: - 200: - description: Subcategories retrieved successfully. - 404: - description: Category not found. - 500: - description: Error occurred. - """ - category = Category.query.get(c_id) - if category is None: - abort(404) - - try: - return { - "subcategories": [sc.to_json() for sc in category.subcategories] - }, 200 - except: - return "Error occured", 500 - - -@app.route('/category//products', methods=['GET']) -def get_category_products(c_id): - """ - Get Products within a Category. - --- - tags: - - Category - description: Get Products for a Category. - parameters: - - in: path - name: c_id - required: true - type: integer - description: Category ID - - in: query - name: page - type: integer - default: 1 - description: Page number - responses: - 200: - description: Products retrieved successfully. - 404: - description: Category not found. - 500: - description: Error occurred. - """ - category_exists = db.session.query(Category.id).filter_by(id=c_id).first() is not None - if not category_exists: - abort(404) - - try: - page = request.args.get("page", default=1, type=int) - - products = ( - Product.query - .join(subcategory_product) - .join(category_subcategory, onclause=subcategory_product.c.subcategory_id == category_subcategory.c.subcategory_id) - .filter(category_subcategory.c.category_id == c_id) - .distinct() - .order_by(Product.id.asc()) - .paginate(page=page, per_page=10, error_out=False) - ) - - return { - "products": [p.to_json() for p in products] - }, 200 - except: - return "Error occured", 500 - - @app.route('/subcategory/create', methods=['POST']) @jwt_required() def create_subcategory(): diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 0000000..d8857a2 --- /dev/null +++ b/app/schemas.py @@ -0,0 +1,55 @@ +from marshmallow import Schema, ValidationError, fields, pre_load, validates +from marshmallow_sqlalchemy import SQLAlchemyAutoSchema, SQLAlchemySchema, auto_field + +from app.models import Category, Product, Subcategory + + +class CategoryOut(SQLAlchemyAutoSchema): + class Meta: + model = Category + + +class CategoriesOut(Schema): + categories = fields.List(fields.Nested(CategoryOut)) + + +class CategoryIn(SQLAlchemySchema): + class Meta: + model = Category + + name = auto_field() + subcategories = fields.List(fields.Int()) + + @pre_load + def strip_strings(self, data, **kwargs): + if "name" in data: + data["name"] = data["name"].strip() + + return data + + @validates("name") + def validate_str_min_len(self, value, data_key): + if len(value) < 1: + raise ValidationError("Cannot be empty") + + +class SubcategoryOut(SQLAlchemyAutoSchema): + class Meta: + model = Subcategory + + +class SubcategoriesOut(Schema): + subcategories = fields.List(fields.Nested(SubcategoryOut)) + + +class ProductOut(SQLAlchemyAutoSchema): + class Meta: + model = Product + + +class ProductsOut(Schema): + products = fields.List(fields.Nested(ProductOut)) + + +class PaginationArgs(Schema): + page = fields.Int(load_default=1) diff --git a/requirements.txt b/requirements.txt index 6629097..886eff0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,3 +8,5 @@ Flask-Migrate==4.1.0 email_validator==1.3.1 email-normalize==0.2.1 Flask-JWT-Extended==4.7.1 +flask-smorest==0.46.2 +marshmallow-sqlalchemy==1.4.2 diff --git a/tests/conftest.py b/tests/conftest.py index 0db66e7..a913359 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -58,7 +58,7 @@ def _create(name, subcategories=None, headers=None): payload = {"name": name} if subcategories is not None: payload["subcategories"] = subcategories - return client.post("/category/create", json=payload, headers=headers) + return client.post("/categories", json=payload, headers=headers) return _create diff --git a/tests/test_category.py b/tests/test_category.py index 8a8279b..2c2f602 100644 --- a/tests/test_category.py +++ b/tests/test_category.py @@ -1,4 +1,7 @@ +import sqlite3 + import pytest +from sqlalchemy.exc import IntegrityError from app.models import Category from tests import utils @@ -36,11 +39,15 @@ def test_create_category(self, create_category): assert "id" in data self._verify_category_in_db(self.TEST_CATEGORY_NAME) - def test_create_category_duplicate_name(self, create_category): - create_category(self.TEST_CATEGORY_NAME) - response = create_category(self.TEST_CATEGORY_NAME) + def test_create_category_duplicate_name(self, create_category, create_authenticated_headers): + headers = create_authenticated_headers() + create_category(self.TEST_CATEGORY_NAME, headers=headers) - assert response.status_code == 500 + with pytest.raises(IntegrityError) as ie: + create_category(self.TEST_CATEGORY_NAME, headers=headers) + + assert isinstance(ie.value.orig, sqlite3.IntegrityError) + assert "UNIQUE constraint failed" in str(ie.value.orig) assert self._count_categories() == 1 self._verify_category_in_db(self.TEST_CATEGORY_NAME) @@ -48,7 +55,7 @@ def test_get_category_by_id(self, create_category): response = create_category("Books") data = response.get_json() cat_id = data["id"] - get_resp = self.client.get(f"/category/{cat_id}") + get_resp = self.client.get(f"/categories/{cat_id}") assert get_resp.status_code == 200 data = get_resp.get_json() @@ -70,14 +77,14 @@ def test_get_all_categories(self, create_category): def test_update_category(self, create_authenticated_headers, create_category): headers = create_authenticated_headers() - response = create_category("OldName", headers) + response = create_category("OldName", headers=headers) data = response.get_json() cat_id = data["id"] update_resp = self.client.put( - f"/category/{cat_id}/update", json={"name": "NewName"}, headers=headers + f"/categories/{cat_id}", json={"name": "NewName"}, headers=headers ) - assert update_resp.status_code == 201 + assert update_resp.status_code == 200 data = update_resp.get_json() assert data["name"] == "NewName" assert data["id"] == cat_id @@ -87,13 +94,13 @@ def test_update_category(self, create_authenticated_headers, create_category): def test_delete_category(self, create_authenticated_headers, create_category): headers = create_authenticated_headers() - response = create_category("ToDelete", headers) + response = create_category("ToDelete", headers=headers) data = response.get_json() cat_id = data["id"] - delete_resp = self.client.delete(f"/category/{cat_id}", headers=headers) + delete_resp = self.client.delete(f"/categories/{cat_id}", headers=headers) - assert delete_resp.status_code == 200 - get_resp = self.client.get(f"/category/{cat_id}") + assert delete_resp.status_code == 204 + get_resp = self.client.get(f"/categories/{cat_id}") assert get_resp.status_code == 404 self._verify_category_in_db("ToDelete", should_exist=False) @@ -108,7 +115,7 @@ def test_delete_category(self, create_authenticated_headers, create_category): def test_create_category_token_error(self, get_headers, expected_code): headers = get_headers(self) response = self.client.post( - "/category/create", json={"name": "CreateTokenError"}, headers=headers + "/categories", json={"name": "CreateTokenError"}, headers=headers ) utils.verify_token_error_response(response, expected_code) self._verify_category_in_db("CreateTokenError", should_exist=False) @@ -121,15 +128,14 @@ def test_create_category_token_error(self, get_headers, expected_code): (lambda self: None, "authorization_required") ] ) - def test_update_category_token_error(self, get_headers, create_category, create_authenticated_headers, expected_code): - headers = create_authenticated_headers() - response = create_category("UpdateTokenError", headers) + def test_update_category_token_error(self, get_headers, create_category, expected_code): + response = create_category("UpdateTokenError") data = response.get_json() cat_id = data["id"] update_headers = get_headers(self) update_resp = self.client.put( - f"/category/{cat_id}/update", + f"/categories/{cat_id}", json={"name": "UpdatedName"}, headers=update_headers, ) @@ -146,14 +152,13 @@ def test_update_category_token_error(self, get_headers, create_category, create_ (lambda self: None, "authorization_required") ] ) - def test_delete_category_token_error(self, get_headers, create_category, create_authenticated_headers, expected_code): - headers = create_authenticated_headers() - response = create_category("DeleteTokenError", headers) + def test_delete_category_token_error(self, get_headers, create_category, expected_code): + response = create_category("DeleteTokenError") data = response.get_json() cat_id = data["id"] delete_headers = get_headers(self) - delete_resp = self.client.delete(f"/category/{cat_id}", headers=delete_headers) + delete_resp = self.client.delete(f"/categories/{cat_id}", headers=delete_headers) utils.verify_token_error_response(delete_resp, expected_code) self._verify_category_in_db("DeleteTokenError") diff --git a/tests/test_relationships.py b/tests/test_relationships.py index 188bb96..6b9bc02 100644 --- a/tests/test_relationships.py +++ b/tests/test_relationships.py @@ -1,6 +1,6 @@ import pytest -from app.models import Category, Subcategory, Product +from app.models import Category, Product, Subcategory class TestRelationships: @@ -91,8 +91,8 @@ def test_update_category_adds_subcategories(self, create_authenticated_headers, category = create_category("U_Cat", subcategories=[subcategory1["id"]]).get_json() headers = create_authenticated_headers() - update_response = self.client.put(f"/category/{category['id']}/update", json={"subcategories": [subcategory2["id"]]}, headers=headers) - assert update_response.status_code == 201 + update_response = self.client.put(f"/categories/{category['id']}", json={"subcategories": [subcategory2["id"]]}, headers=headers) + assert update_response.status_code == 200 assert self._category_subcategory_ids(category["id"]) == sorted([subcategory1["id"], subcategory2["id"]]) @@ -127,7 +127,7 @@ def test_update_product_adds_subcategories(self, create_authenticated_headers, c def test_get_category_subcategories_empty(self, create_category): category = create_category("Cat_NoSC").get_json() - resp = self.client.get(f"/category/{category['id']}/subcategories") + resp = self.client.get(f"/categories/{category['id']}/subcategories") self._assert_related_collection(resp, "subcategories") def test_get_category_subcategories_populated(self, create_category, create_subcategory): @@ -135,12 +135,12 @@ def test_get_category_subcategories_populated(self, create_category, create_subc subcategory2 = create_subcategory("SC2").get_json() category = create_category("Cat_WithSC", subcategories=[subcategory1["id"], subcategory2["id"]]).get_json() - resp = self.client.get(f"/category/{category['id']}/subcategories") + resp = self.client.get(f"/categories/{category['id']}/subcategories") self._assert_related_collection(resp, "subcategories", expected_ids=[subcategory1["id"], subcategory2["id"]]) def test_get_category_products_empty(self, create_category): category = create_category("Cat_NoProd").get_json() - resp = self.client.get(f"/category/{category['id']}/products") + resp = self.client.get(f"/categories/{category['id']}/products") self._assert_related_collection(resp, "products") def test_get_category_products_populated_with_pagination(self, create_category, create_subcategory, create_product): @@ -152,8 +152,8 @@ 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"/category/{category['id']}/products?page=1").get_json() - page2 = self.client.get(f"/category/{category['id']}/products?page=2").get_json() + 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() assert len(page1["products"]) == 10 assert len(page2["products"]) == 2 @@ -210,8 +210,8 @@ def test_get_product_subcategories_populated(self, create_product, create_subcat @pytest.mark.parametrize( "path", [ - "/category/999999/subcategories", - "/category/999999/products", + "/categories/999999/subcategories", + "/categories/999999/products", "/subcategory/999999/categories", "/subcategory/999999/products", "/product/999999/subcategories",