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/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
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..e52c8ce
--- /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(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/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()
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")