From 11e0ffb374abd29eb86aa991aec17f563f5bf831 Mon Sep 17 00:00:00 2001 From: piyush-jaiswal Date: Sun, 12 Oct 2025 19:28:01 +0530 Subject: [PATCH 1/4] migrate auth endpoints to flask-smorest and complete migration --- app/__init__.py | 10 +- app/routes.py | 135 --------------- app/{migrated_routes => routes}/__init__.py | 0 app/routes/auth.py | 154 ++++++++++++++++++ app/{migrated_routes => routes}/category.py | 0 app/{migrated_routes => routes}/product.py | 0 .../subcategory.py | 0 app/schemas.py | 25 ++- 8 files changed, 182 insertions(+), 142 deletions(-) delete mode 100644 app/routes.py rename app/{migrated_routes => routes}/__init__.py (100%) create mode 100644 app/routes/auth.py rename app/{migrated_routes => routes}/category.py (100%) rename app/{migrated_routes => routes}/product.py (100%) rename app/{migrated_routes => routes}/subcategory.py (100%) diff --git a/app/__init__.py b/app/__init__.py index 5d3dc88..11e93a7 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -12,13 +12,15 @@ def register_blueprints(): - from app.migrated_routes.category import bp as category_bp - from app.migrated_routes.subcategory import bp as subcategory_bp - from app.migrated_routes.product import bp as product_bp + from app.routes.category import bp as category_bp + from app.routes.subcategory import bp as subcategory_bp + from app.routes.product import bp as product_bp + from app.routes.auth import bp as auth_bp api.register_blueprint(category_bp, url_prefix="/categories") api.register_blueprint(subcategory_bp, url_prefix="/subcategories") api.register_blueprint(product_bp, url_prefix="/products") + api.register_blueprint(auth_bp, url_prefix="/auth") app = Flask(__name__) @@ -73,8 +75,6 @@ def missing_token_callback(error): return jsonify(code="authorization_required", error="JWT needed for this operation. Login, if needed."), 401 -from app import routes - swagger_config = { 'openapi': '3.0.0', 'title': 'Ecommerce REST API', diff --git a/app/routes.py b/app/routes.py deleted file mode 100644 index 6519bf0..0000000 --- a/app/routes.py +++ /dev/null @@ -1,135 +0,0 @@ -from flask import request, abort, jsonify -from flask_jwt_extended import create_access_token, create_refresh_token, get_jwt_identity, jwt_required -from sqlalchemy.exc import IntegrityError -from email_validator import EmailNotValidError - -from app import app, db -from app.models import User - - -@app.route('/auth/register', methods=['POST']) -def register(): - """ - 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. - """ - if not request.json: - abort(400) - - try: - email = request.json.get('email') - password = request.json.get('password') - if not email or not password: - abort(400) - - user = User() - user.set_email(email) - user.set_password(password) - db.session.add(user) - db.session.commit() - return { "message": "Registered!" }, 201 - except IntegrityError: - return jsonify({'error': 'Email already exists'}), 409 - except EmailNotValidError as e: - return jsonify(code='invalid_email_format', error=str(e)), 400 - - -@app.route('/auth/login', methods=['POST']) -def login(): - """ - 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 username or password. - 500: - description: Internal Server Error. - """ - if not request.json: - abort(400) - - email = request.json.get('email') - password = request.json.get('password') - if not email or not password: - abort(400) - - user = User.get(email=email) - if not user or not user.check_password(password): - return jsonify(error='Invalid username or password. Check again or register.'), 401 - - access_token = create_access_token(identity=str(user.id), fresh=True) - refresh_token = create_refresh_token(identity=str(user.id)) - return jsonify(access_token=access_token, refresh_token=refresh_token), 200 - - -@app.route('/auth/refresh', methods=['POST']) -@jwt_required(refresh=True) -def refresh(): - """ - 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() - access_token = create_access_token(identity=identity, fresh=False) - return jsonify(access_token=access_token), 200 diff --git a/app/migrated_routes/__init__.py b/app/routes/__init__.py similarity index 100% rename from app/migrated_routes/__init__.py rename to app/routes/__init__.py diff --git a/app/routes/auth.py b/app/routes/auth.py new file mode 100644 index 0000000..d7bf0c2 --- /dev/null +++ b/app/routes/auth.py @@ -0,0 +1,154 @@ +from email_validator import EmailNotValidError +from flask import jsonify, make_response +from flask.views import MethodView +from flask_jwt_extended import ( + create_access_token, + create_refresh_token, + get_jwt_identity, + jwt_required, +) +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import IntegrityError + +from app import db +from app.models import User +from app.schemas import AuthIn, AuthOut + +bp = Blueprint("auth", __name__) + + +@bp.route("/register") +class Register(MethodView): + @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"]) + + try: + user.set_email(data["email"]) + db.session.add(user) + db.session.commit() + except IntegrityError: + db.session.rollback() + abort(make_response(jsonify(error="Email already exists"), 409)) + except EmailNotValidError as e: + abort( + make_response(jsonify(code="invalid_email_format", error=str(e)), 422) + ) + + return {"message": "Registered!"} + + +@bp.route("/login") +class Login(MethodView): + """Login a user and return access & refresh tokens.""" + + @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( + make_response( + jsonify( + error="Invalid email or password. Check again or register." + ), + 401, + ) + ) + + return { + "access_token": create_access_token(identity=str(user.id), fresh=True), + "refresh_token": create_refresh_token(identity=str(user.id)), + } + + +@bp.route("/refresh") +class Refresh(MethodView): + """Get new access token using your refresh token.""" + + @jwt_required(refresh=True) + @bp.response(200, AuthOut(partial=("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/migrated_routes/category.py b/app/routes/category.py similarity index 100% rename from app/migrated_routes/category.py rename to app/routes/category.py diff --git a/app/migrated_routes/product.py b/app/routes/product.py similarity index 100% rename from app/migrated_routes/product.py rename to app/routes/product.py diff --git a/app/migrated_routes/subcategory.py b/app/routes/subcategory.py similarity index 100% rename from app/migrated_routes/subcategory.py rename to app/routes/subcategory.py diff --git a/app/schemas.py b/app/schemas.py index 21d5b35..59d836b 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -1,7 +1,7 @@ -from marshmallow import Schema, ValidationError, fields, pre_load, validates +from marshmallow import Schema, ValidationError, fields, pre_load, validate, validates from marshmallow_sqlalchemy import SQLAlchemyAutoSchema, SQLAlchemySchema, auto_field -from app.models import Category, Product, Subcategory +from app.models import Category, Product, Subcategory, User class CategoryOut(SQLAlchemyAutoSchema): @@ -101,3 +101,24 @@ class NameArgs(Schema): class PaginationArgs(Schema): page = fields.Int(load_default=1) + + +# class AuthIn(Schema): +class AuthIn(SQLAlchemySchema): + class Meta: + model = User + + # email validation handled in User model + email = auto_field() + password = fields.Str(required=True, validate=validate.Length(min=1)) + + @pre_load + def strip_strings(self, data, **kwargs): + if "email" in data and data["email"] is not None: + data["email"] = data["email"].strip() + return data + + +class AuthOut(Schema): + access_token = fields.Str() + refresh_token = fields.Str() From e34866ea91c68ce37c1fc70fa9368402e8206b88 Mon Sep 17 00:00:00 2001 From: piyush-jaiswal Date: Sun, 12 Oct 2025 19:28:37 +0530 Subject: [PATCH 2/4] update auth tests --- tests/test_auth.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_auth.py b/tests/test_auth.py index 49ba61e..28d567e 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -33,7 +33,7 @@ def _count_users(self): with self.client.application.app_context(): return User.query.count() - def _test_invalid_request_data(self, endpoint, expected_status=400): + def _test_invalid_request_data(self, endpoint, expected_status=422): response = self.client.post(endpoint, json={}) assert response.status_code == expected_status @@ -44,7 +44,7 @@ def _test_invalid_request_data(self, endpoint, expected_status=400): assert response.status_code == expected_status response = self.client.post(endpoint, data="not json data") - assert response.status_code == 415 + assert response.status_code == expected_status def _decode_token(self, token): # Needs Flask app context for secret/algorithms from current_app.config @@ -97,7 +97,7 @@ def test_register_invalid_email_format(self, register_user): invalid_email = "not-an-email" response = register_user(invalid_email, self.TEST_PASSWORD) - assert response.status_code == 400 + assert response.status_code == 422 data = response.get_json() assert data["code"] == "invalid_email_format" assert "error" in data @@ -125,7 +125,7 @@ def test_login_invalid_password(self, register_user, login_user): response = login_user(self.TEST_EMAIL, "wrongpassword") assert response.status_code == 401 - assert b"Invalid username or password" in response.data + assert b"Invalid email or password" in response.data def test_login_invalid_data(self): self._test_invalid_request_data("/auth/login") From 9ebfa0bd9e8c0fa380013a574378ddde08762c1d Mon Sep 17 00:00:00 2001 From: piyush-jaiswal Date: Sun, 12 Oct 2025 19:29:01 +0530 Subject: [PATCH 3/4] Remove redundant raise --- app/models.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/app/models.py b/app/models.py index 8b9b8c5..459f0e8 100644 --- a/app/models.py +++ b/app/models.py @@ -48,11 +48,8 @@ def get(email): return User.query.filter_by(email_normalized=email_normalized).scalar() def set_email(self, email): - try: - self.email_normalized = self._normalize_email(email) - self.email = email - except EmailNotValidError as e: - raise e + self.email_normalized = self._normalize_email(email) + self.email = email def set_password(self, password): # scrypt stores salt with the hash, which it uses to verify the password From 0d16c6513410260e084a1f8ae2959163b22511b2 Mon Sep 17 00:00:00 2001 From: piyush-jaiswal Date: Sun, 12 Oct 2025 20:06:09 +0530 Subject: [PATCH 4/4] replace partial with only --- app/routes/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/routes/auth.py b/app/routes/auth.py index d7bf0c2..e52c8ce 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -132,7 +132,7 @@ class Refresh(MethodView): """Get new access token using your refresh token.""" @jwt_required(refresh=True) - @bp.response(200, AuthOut(partial=("access_token",))) + @bp.response(200, AuthOut(only=("access_token",))) def post(self): """ Get new access token using your refresh token