diff --git a/app/__init__.py b/app/__init__.py index 11e93a7..5f11444 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -6,7 +6,6 @@ from flask_migrate import Migrate from flask_sqlalchemy import SQLAlchemy from dotenv import load_dotenv -from flasgger import Swagger from sqlalchemy import MetaData from flask_smorest import Api @@ -41,6 +40,31 @@ def register_blueprints(): app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.2" +# flask-smorest openapi swagger +app.config["OPENAPI_URL_PREFIX"] = "/" +app.config["OPENAPI_SWAGGER_UI_PATH"] = "/" +app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + +# flask-smorest Swagger UI top level authorize dialog box +app.config["API_SPEC_OPTIONS"] = { + "components": { + "securitySchemes": { + "access_token": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT", + "description": "Enter your JWT access token", + }, + "refresh_token": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT", + "description": "Enter your JWT refresh token", + }, + } + } +} + # 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 = { @@ -73,47 +97,3 @@ def invalid_token_callback(error): @jwt.unauthorized_loader def missing_token_callback(error): return jsonify(code="authorization_required", error="JWT needed for this operation. Login, if needed."), 401 - - -swagger_config = { - 'openapi': '3.0.0', - 'title': 'Ecommerce REST API', - 'version': None, - 'termsOfService': None, - 'description': None, - 'specs': [ - { - "endpoint": 'api_spec', - "route": '/api_spec.json', - "rule_filter": lambda rule: True, # all in - "model_filter": lambda tag: True, # all in - } - ], - 'components': { - 'securitySchemes': { - 'access_token': { - 'type': 'http', - 'scheme': 'bearer', - 'bearerFormat': 'JWT', - 'description': 'Enter your JWT access token' - }, - 'refresh_token': { - 'type': 'http', - 'scheme': 'bearer', - 'bearerFormat': 'JWT', - 'description': 'Enter your JWT refresh token' - } - } - }, - 'specs_route': '/' -} - -template = { - 'tags': [ - {'name': 'Category', 'description': 'Operations with categories'}, - {'name': 'Subcategory', 'description': 'Operations with subategories'}, - {'name': 'Product', 'description': 'Operations with products'}, - {'name': 'User', 'description': 'Operations with users'}, - ] -} -swagger = Swagger(app, template=template, config=swagger_config, merge=True) diff --git a/app/routes/auth.py b/app/routes/auth.py index e52c8ce..1a5b0d9 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -14,46 +14,15 @@ from app.models import User from app.schemas import AuthIn, AuthOut -bp = Blueprint("auth", __name__) +bp = Blueprint("Auth", __name__) @bp.route("/register") class Register(MethodView): + @bp.doc(summary="Register a new user") @bp.arguments(AuthIn) @bp.response(201) def post(self, data): - """ - Register a new user. - --- - tags: - - User - description: Register a new user. - requestBody: - required: true - description: email - Email id
password - Password - content: - application/json: - schema: - type: object - required: - - email - - password - properties: - email: - type: string - password: - type: string - responses: - 201: - description: User registered successfully. - 400: - description: Invalid input. - 409: - description: Email already exists. - 500: - description: Internal Server Error. - """ - user = User() user.set_password(data["password"]) @@ -74,42 +43,10 @@ def post(self, data): @bp.route("/login") class Login(MethodView): - """Login a user and return access & refresh tokens.""" - + @bp.doc(summary="Login a user") @bp.arguments(AuthIn) @bp.response(200, AuthOut) def post(self, data): - """ - Login a user. - --- - tags: - - User - description: Login a user. - requestBody: - required: true - description: email - Email id
password - Password - content: - application/json: - schema: - type: object - required: - - email - - password - properties: - email: - type: string - password: - type: string - responses: - 200: - description: User logged in successfully. - 400: - description: Invalid input. - 401: - description: Invalid email or password. - 500: - description: Internal Server Error. - """ user = User.get(email=data["email"]) if not user or not user.check_password(data["password"]): return abort( @@ -129,26 +66,12 @@ def post(self, data): @bp.route("/refresh") class Refresh(MethodView): - """Get new access token using your refresh token.""" - @jwt_required(refresh=True) + @bp.doc( + summary="Get new access token using your refresh token", + security=[{"refresh_token": []}], + ) @bp.response(200, AuthOut(only=("access_token",))) def post(self): - """ - Get new access token using your refresh token - --- - tags: - - User - description: Get new access token using your refresh token. - security: - - refresh_token: [] - responses: - 200: - description: New access token. - 401: - description: Token expired, missing or invalid. - 500: - description: Internal Server Error. - """ identity = get_jwt_identity() return {"access_token": create_access_token(identity=identity, fresh=False)} diff --git a/app/routes/category.py b/app/routes/category.py index 1be817c..d684825 100644 --- a/app/routes/category.py +++ b/app/routes/category.py @@ -21,7 +21,7 @@ SubcategoriesOut, ) -bp = Blueprint("category", __name__) +bp = Blueprint("Category", __name__) @bp.route("") @@ -41,58 +41,16 @@ def _get_name_unique_constraint(): _NAME_UNIQUE_CONSTRAINT = _get_name_unique_constraint() + @bp.doc(summary="Get All Categories") @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.doc(summary="Create Category", security=[{"access_token": []}]) @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"): @@ -125,70 +83,16 @@ class CategoryById(MethodView): def _get(self, id): return Category.query.get_or_404(id) + @bp.doc(summary="Get Category") @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.doc(summary="Update Category", security=[{"access_token": []}]) @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 @@ -221,30 +125,9 @@ def put(self, data, id): return category @jwt_required() + @bp.doc(summary="Delete Category", security=[{"access_token": []}]) @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() @@ -254,28 +137,9 @@ def delete(self, id): class CategorySubcategories(MethodView): init_every_request = False + @bp.doc(summary="Get Subcategories within a Category") @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} @@ -285,34 +149,10 @@ class CategoryProducts(MethodView): init_every_request = False _PER_PAGE = 10 + @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): - """ - 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) diff --git a/app/routes/product.py b/app/routes/product.py index b0f3614..48a35af 100644 --- a/app/routes/product.py +++ b/app/routes/product.py @@ -20,7 +20,7 @@ SubcategoriesOut, ) -bp = Blueprint("product", __name__) +bp = Blueprint("Product", __name__) @bp.route("") @@ -44,30 +44,14 @@ def _get_name_unique_constraint(): def _get_by_name(self, name): return Product.query.filter(Product.name == name) + @bp.doc( + summary="Get All Products", + description="If name is passed, filters the result to that single product, if present", + ) @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): - """ - Get All Products - --- - tags: - - Product - description: Get all products. - parameters: - - in: query - name: page - type: integer - default: 1 - description: Page number - - in: query - name: name - type: string - description: Name - responses: - 200: - description: Product by name or a paginated list of all products. - """ if name is not None: products = self._get_by_name(name) else: @@ -78,45 +62,10 @@ def get(self, name, page): return {"products": products} @jwt_required() + @bp.doc(summary="Create Product", security=[{"access_token": []}]) @bp.arguments(ProductIn) @bp.response(201, ProductOut) def post(self, data): - """ - Create Product - --- - tags: - - Product - description: Create a new product. - security: - - access_token: [] - requestBody: - required: true - description: name - Name of the product
description - Description of the product (optional)
subcategories - Array of subcategory ids (optional) - content: - application/json: - schema: - type: object - required: - - name - properties: - name: - type: string - description: - type: string - subcategories: - type: array - items: - type: integer - responses: - 201: - description: Product created successfully. - 400: - description: Invalid input. - 401: - description: Token expired, missing or invalid. - 500: - description: Error occurred. - """ product = Product(name=data["name"], description=data.get("description")) if sc_ids := data.get("subcategories"): @@ -148,74 +97,16 @@ class ProductById(MethodView): def _get(self, id): return Product.query.get_or_404(id) + @bp.doc(summary="Get Product") @bp.response(200, ProductOut) def get(self, id): - """ - Get Product - --- - tags: - - Product - description: Get a product by ID. - parameters: - - in: path - name: id - required: true - type: integer - description: Product ID - responses: - 200: - description: Product retrieved successfully. - 404: - description: Product not found. - """ return self._get(id) @jwt_required() + @bp.doc(summary="Update Product", security=[{"access_token": []}]) @bp.arguments(ProductIn(partial=("name",))) @bp.response(200, ProductOut) def put(self, data, id): - """ - Update Product - --- - tags: - - Product - description: Update an existing product. - security: - - access_token: [] - consumes: - - application/json - parameters: - - in: path - name: id - required: true - type: integer - description: Product ID - requestBody: - required: true - description: name - Name of the product (optional)
description = Description of the product (optional)
subcategories - Array of subcategory ids (optional) - content: - application/json: - schema: - type: object - properties: - name: - type: string - description: - type: string - subcategories: - type: array - items: - type: integer - responses: - 201: - description: Product updated successfully. - 400: - description: Invalid input. - 404: - description: Product not found. - 500: - description: Error occurred. - """ product = self._get(id) if name := data.get("name"): @@ -252,30 +143,9 @@ def put(self, data, id): return product @jwt_required() + @bp.doc(summary="Delete Product", security=[{"access_token": []}]) @bp.response(204) def delete(self, id): - """ - Delete Product - --- - tags: - - Product - description: Delete a product by ID. - security: - - access_token: [] - parameters: - - in: path - name: id - required: true - type: integer - description: Product ID - responses: - 200: - description: Product deleted successfully. - 404: - description: Product not found. - 500: - description: Error occurred. - """ product = self._get(id) db.session.delete(product) db.session.commit() @@ -285,27 +155,8 @@ def delete(self, id): class ProductSubcategories(MethodView): init_every_request = False + @bp.doc(summary="Get Subcategories related to a Product") @bp.response(200, SubcategoriesOut) def get(self, id): - """ - Get Subcategories related to a Product. - --- - tags: - - Product - description: Get Subcategories related to a Product. - parameters: - - in: path - name: id - required: true - type: integer - description: Product ID - responses: - 200: - description: Subcategories retrieved successfully. - 404: - description: Product not found. - 500: - description: Error occurred. - """ product = Product.query.get_or_404(id) return {"subcategories": product.subcategories} diff --git a/app/routes/subcategory.py b/app/routes/subcategory.py index 635fab2..371b632 100644 --- a/app/routes/subcategory.py +++ b/app/routes/subcategory.py @@ -22,7 +22,7 @@ SubcategoryOut, ) -bp = Blueprint("subcategory", __name__) +bp = Blueprint("Subcategory", __name__) @bp.route("") @@ -42,60 +42,16 @@ def _get_name_unique_constraint(): _NAME_UNIQUE_CONSTRAINT = _get_name_unique_constraint() + @bp.doc(summary="Get All Subcategories") @bp.response(200, SubcategoriesOut) def get(self): - """ - Get All Subcategories - --- - tags: - - Subcategory - description: Get all subcategories. - responses: - 200: - description: A list of subcategories. - """ return {"subcategories": Subcategory.query.all()} @jwt_required() + @bp.doc(summary="Create Subcategory", security=[{"access_token": []}]) @bp.arguments(SubcategoryIn) @bp.response(201, SubcategoryOut) def post(self, data): - """ - Create Subcategory - --- - tags: - - Subcategory - description: Create a new subcategory. - security: - - access_token: [] - requestBody: - required: true - description: name - Name of the subcategory
categories - Array of category ids (optional)
products - Array of product ids (optional) - content: - application/json: - schema: - type: object - required: - - name - properties: - name: - type: string - categories: - type: array - items: - type: integer - products: - type: array - items: - type: integer - responses: - 201: - description: Subcategory created successfully. - 400: - description: Invalid input. - 500: - description: Error occurred. - """ subcategory = Subcategory(name=data["name"]) if c_ids := data.get("categories"): @@ -133,74 +89,16 @@ class SubcategoryById(MethodView): def _get(self, id): return Subcategory.query.get_or_404(id) + @bp.doc(summary="Get Subcategory") @bp.response(200, SubcategoryOut) def get(self, id): - """ - Get Subcategory - --- - tags: - - Subcategory - description: Get a subcategory by ID. - parameters: - - in: path - name: id - required: true - type: integer - description: Subcategory ID - responses: - 200: - description: Subcategory retrieved successfully. - 404: - description: Subcategory not found. - """ return self._get(id) @jwt_required() + @bp.doc(summary="Update Subcategory", security=[{"access_token": []}]) @bp.arguments(SubcategoryIn(partial=("name",))) @bp.response(200, SubcategoryOut) def put(self, data, id): - """ - Update Subcategory - --- - tags: - - Subcategory - description: Update an existing subcategory. - security: - - access_token: [] - parameters: - - in: path - name: id - required: true - type: integer - description: Subcategory ID - requestBody: - required: true - description: name - Name of the subcategory (optional)
categories - Array of category ids (optional)
products - Array of product ids (optional) - content: - application/json: - schema: - type: object - properties: - name: - type: string - categories: - type: array - items: - type: integer - products: - type: array - items: - type: integer - responses: - 200: - description: Subcategory updated successfully. - 400: - description: Invalid input. - 404: - description: Subcategory not found. - 500: - description: Error occurred. - """ subcategory = self._get(id) if name := data.get("name"): subcategory.name = name @@ -244,30 +142,9 @@ def put(self, data, id): return subcategory @jwt_required() + @bp.doc(summary="Delete Subcategory", security=[{"access_token": []}]) @bp.response(204) def delete(self, id): - """ - Delete Subcategory - --- - tags: - - Subcategory - description: Delete a subcategory by ID. - security: - - access_token: [] - parameters: - - in: path - name: id - required: true - type: integer - description: Subcategory ID - responses: - 204: - description: Subcategory deleted successfully. - 404: - description: Subcategory not found. - 500: - description: Error occurred. - """ subcategory = self._get(id) db.session.delete(subcategory) db.session.commit() @@ -277,28 +154,9 @@ def delete(self, id): class SubcategoryCategories(MethodView): init_every_request = False + @bp.doc(summary="Get Categories related to a Subcategory") @bp.response(200, CategoriesOut) def get(self, id): - """ - Get Categories related to a Subcategory. - --- - tags: - - Subcategory - description: Get Categories related to a Subcategory. - parameters: - - in: path - name: id - required: true - type: integer - description: Subcategory ID - responses: - 200: - description: Categories retrieved successfully. - 404: - description: Subcategory not found. - 500: - description: Error occurred. - """ subcategory = Subcategory.query.get_or_404(id) return {"categories": subcategory.categories} @@ -308,34 +166,10 @@ class SubcategoryProducts(MethodView): init_every_request = False _PER_PAGE = 10 + @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): - """ - Get Products within a Subcategory. - --- - tags: - - Subcategory - description: Get products for a subcategory. - parameters: - - in: path - name: id - required: true - type: integer - description: Subcategory ID - - in: query - name: page - type: integer - default: 1 - description: Page number - responses: - 200: - description: Products retrieved successfully. - 404: - description: Subcategory not found. - 500: - description: Error occurred. - """ subcategory = Subcategory.query.get_or_404(id) products = subcategory.products.order_by(Product.id.asc()).paginate( diff --git a/requirements.txt b/requirements.txt index 886eff0..b7a4dae 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,6 @@ Flask-SQLAlchemy==3.1.1 # psycopg2==2.9.10 # vercel complains! psycopg2-binary==2.9.10 python-dotenv==1.0.1 -flasgger==0.9.7.1 Flask-Migrate==4.1.0 email_validator==1.3.1 email-normalize==0.2.1