Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
9885558
add Flask-PyMongo extension and init in create_app factory
codesungrape Aug 5, 2025
7fd0265
Add users_db_setup fixture to clean users collection
codesungrape Aug 5, 2025
b728891
Add tests/test_auth.py with register and duplicate-email tests
codesungrape Aug 5, 2025
18ab2fd
Run 'make format' command for formatting files
codesungrape Aug 5, 2025
7a3c268
Use Flask Blueprint and restructure app layout
codesungrape Aug 6, 2025
4fc903f
Update tests to reflect post Flask Blueprint restructure
codesungrape Aug 6, 2025
9719e03
Patch mongo connection with mock for Flask-Pymongo
codesungrape Aug 6, 2025
52809eb
Run 'make format' command for formatting
codesungrape Aug 6, 2025
84e3d30
Add flask-bcrypt dependency to requirements.txt
codesungrape Aug 6, 2025
c5e262c
Add user registration logic with validation and hashing
codesungrape Aug 6, 2025
3ff6916
Add edge case tests for empty/invalid JSON
codesungrape Aug 6, 2025
b26742d
Change all errors to use 'message' for consistency
codesungrape Aug 6, 2025
dee4d85
Add test for missing fields with parametrize to avoid DRY
codesungrape Aug 6, 2025
ee0b1bf
Run formatting makefile command
codesungrape Aug 6, 2025
23a7c74
Rename variable to avoid Pylint duplicate code err
codesungrape Aug 7, 2025
06051c7
Refactor/ app/__init__.py handles imports and registration
codesungrape Aug 7, 2025
6819bcd
Run formatting and fic spelling/typos
codesungrape Aug 7, 2025
e8df69e
Break circular import cycle with an extensions module
codesungrape Aug 8, 2025
62a77e8
Update openapi.yml for /auth/register endpoint.
codesungrape Aug 8, 2025
b0a9a43
Update openapi.yml
codesungrape Aug 8, 2025
7fb13a8
Update app/extensions.py
codesungrape Aug 8, 2025
889a975
Update app/extensions.py
codesungrape Aug 8, 2025
8d04e6d
Update tests/test_auth.py
codesungrape Aug 8, 2025
a008464
Run formatting
codesungrape Aug 8, 2025
d17ce4f
Add failing test for invalid email formats
codesungrape Aug 8, 2025
e779ede
Update test to test behavior vs the msg
codesungrape Aug 8, 2025
a931f25
Install email-validator lib, use in register_user()
codesungrape Aug 8, 2025
6fe3843
Run formatting
codesungrape Aug 8, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,32 @@
import os

from flask import Flask
from flask_pymongo import PyMongo

from app.config import Config
from app.extensions import mongo


def create_app(test_config=None):
"""Application factory pattern."""

app = Flask(__name__)

# 1. Load the default configuration from the Config object.
app.config.from_object(Config)

if test_config: # Override with test specifics
app.config.from_mapping(test_config)

# Import routes
from app.routes import \
register_routes # pylint: disable=import-outside-toplevel
# Connect Pymongo to our specific app instance
mongo.init_app(app)

# Import blueprints inside the factory
from app.routes.auth_routes import \
auth_bp # pylint: disable=import-outside-toplevel
from app.routes.legacy_routes import \
register_legacy_routes # pylint: disable=import-outside-toplevel

register_routes(app)
# Register routes with app instance
register_legacy_routes(app)
app.register_blueprint(auth_bp)

return app
7 changes: 4 additions & 3 deletions app/config.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
# pylint: disable=too-few-public-methods

"""
Application configuration module for Flask.
The central, organized place for the application's settings.

Loads environment variables (and other sensitive values) from a .env file and
Defines the Config class to be used.

Loads environment variables from a .env file and defines the Config class
used to configure Flask and database connection settings.
"""

import os
Expand Down
4 changes: 3 additions & 1 deletion app/datastore/mongo_helper.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"""Module containing pymongo helper functions."""

from bson.objectid import InvalidId, ObjectId
from pymongo.cursor import Cursor
from pymongo.collection import Collection
from pymongo.cursor import Cursor


def insert_book_to_mongo(book_data, collection):
Expand Down Expand Up @@ -83,8 +83,10 @@ def delete_book_by_id(book_collection: Collection, book_id: str):

return result


# ------ PUT helpers ------------


def validate_book_put_payload(payload: dict):
"""
Validates the payload for a PUT request.
Expand Down
7 changes: 7 additions & 0 deletions app/extensions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""Module for Flask extensions."""

from flask_pymongo import PyMongo

# Createempty PyMongo extension object globally
# This way, we can import it in other files and avoid a code smell: tighly-coupled, cyclic error
mongo = PyMongo()
Empty file added app/routes/__init__.py
Empty file.
76 changes: 76 additions & 0 deletions app/routes/auth_routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# pylint: disable=cyclic-import
"""Routes for authorization for the JWT upgrade"""

import bcrypt
from email_validator import EmailNotValidError, validate_email
from flask import Blueprint, jsonify, request
from werkzeug.exceptions import BadRequest

from app.extensions import mongo

auth_bp = Blueprint("auth_bp", __name__, url_prefix="/auth")


@auth_bp.route("/register", methods=["POST"])
def register_user():
"""
Registers a new user.
Takes a JSON payload with "email" and "password".
It verfies it is not a duplicate email,
Hashes the password and stores the new user in the database.
"""

# VALIDATION the incoming data/request payload
try:
data = request.get_json()
if not data:
return jsonify({"message": "Request body cannot be empty"}), 400

email = data.get("email")
password = data.get("password")

if not email or not password:
return jsonify({"message": "Email and password are required"}), 400

# email-validator
try:
valid = validate_email(email, check_deliverability=False)

# use the normalized email for all subsequent operations
email = valid.normalized
except EmailNotValidError as e:
return jsonify({"message": str(e)}), 400

except BadRequest:
return jsonify({"message": "Invalid JSON format"}), 400

# Check for Duplicate User
# Easy access with Flask_PyMongo's 'mongo'
if mongo.db.users.find_one({"email": email}):
return jsonify({"message": "Email is already registered"}), 409

# Password Hashing
# Generate a salt and hash the password
# result is a byte object representing the final hash
hashed_password = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt())

# Database Insertion
user_id = mongo.db.users.insert_one(
{
"email": email,
# The hash is stored as a string in the DB
"password_hash": hashed_password.decode("utf-8"),
}
).inserted_id
print(user_id)
Copy link

Copilot AI Aug 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The print statement should be removed from production code. Consider using proper logging instead if debugging information is needed.

Suggested change
print(user_id)
logging.info("Registered new user with id: %s", user_id)

Copilot uses AI. Check for mistakes.

# Prepare response
return (
jsonify(
{
"message": "User registered successfully",
"user": {"id": str(user_id), "email": email},
}
),
201,
)
2 changes: 1 addition & 1 deletion app/routes.py → app/routes/legacy_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from app.utils.helper import append_hostname


def register_routes(app): # pylint: disable=too-many-statements
def register_legacy_routes(app): # pylint: disable=too-many-statements
"""
Register all Flask routes with the given app instance.

Expand Down
111 changes: 111 additions & 0 deletions openapi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ tags:
description: Find out more
url: example.com

- name: Authentication
description: Operations related to user registration and login

# --------------------------------------------
# Components
components:
Expand Down Expand Up @@ -108,6 +111,57 @@ components:
type: array
items:
$ref: '#/components/schemas/BookOutput'

# ----- AUTH schemas -------------

# Schema for the data client POSTs to register a user
UserRegistrationInput:
type: object
properties:
email:
type: string
format: email
description: The user's email address.
example: "newuser@example.com"
password:
type: string
format: password # This format is a hint for UI tools to obscure the input
description: The user's desired password.
minLength: 8 # It's good practice to suggest a minimum length
example: "a-very-secure-password"
required:
- email
- password

# Schema for the User object as returned by the server on success
UserOutput:
type: object
properties:
id:
type: string
description: The unique 24-character hexadecimal identifier for the user (MongoDB ObjectId).
readOnly: true
example: "635f3a7e3a8e3bcfc8e6a1e0"
email:
type: string
format: email
readOnly: true
example: "newuser@example.com"

# Schema for the successful registration response
RegistrationSuccess:
type: object
properties:
message:
type: string
example: "User registered successfully"
user:
$ref: '#/components/schemas/UserOutput'




# ------ ERROR schemas ----------

# Generic Error schema
StandardError:
Expand Down Expand Up @@ -144,6 +198,16 @@ components:
required:
- error

# Schema for a simple message response (useful for errors)
MessageError:
type: object
properties:
message:
type: string
description: A brief error message.
required:
- message

# API Error: Reusable responses for common errors
responses:
BadRequest:
Expand Down Expand Up @@ -426,3 +490,50 @@ paths:
error: "Book not found"
'500':
$ref: '#/components/responses/InternalServerError'
# --------------------------------------------
/auth/register:
# --------------------------------------------
post:
tags:
- Authentication
summary: Register a new user
description: >-
Creates a new user account. The server will hash the password and store the user details, returning a unique ID for the new user.
operationId: registerUser
requestBody:
description: User's email and password for registration.
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UserRegistrationInput'
responses:
'201':
description: User registered successfully.
content:
application/json:
schema:
$ref: '#/components/schemas/RegistrationSuccess'
'400':
description: Bad Request. The request body is missing, not valid JSON, or is missing required fields.
content:
application/json:
schema:
$ref: '#/components/schemas/MessageError'
examples:
missingFields:
summary: Missing Fields
value:
message: "Email and password are required"
emptyBody:
summary: Empty Body
value:
message: "Request body cannot be empty"
'409':
description: Conflict. The provided email is already registered.
content:
application/json:
schema:
$ref: '#/components/schemas/MessageError' # Reuse the error schema again!
example:
message: "Email is already registered"
5 changes: 4 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,7 @@ pymongo
python-dotenv
mongomock
black
isort
isort
flask_pymongo
flask-bcrypt
email-validator
33 changes: 31 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,15 @@
import mongomock
import pytest

from app import create_app
from app import create_app, mongo
from app.datastore.mongo_db import get_book_collection


@pytest.fixture(name="_insert_book_to_db")
def stub_insert_book():
"""Fixture that mocks insert_book_to_mongo() to prevent real DB writes during tests. Returns a mock with a fixed inserted_id."""

with patch("app.routes.insert_book_to_mongo") as mock_insert_book:
with patch("app.routes.legacy_routes.insert_book_to_mongo") as mock_insert_book:
mock_insert_book.return_value.inserted_id = "12345"
yield mock_insert_book

Expand Down Expand Up @@ -78,6 +78,17 @@ def test_app():
"COLLECTION_NAME": "test_books",
}
)
# The application now uses the Flask-PyMongo extension,
# which requires initialization via `init_app`.
# In the test environment, the connection to a real database fails,
# leaving `mongo.db` as None.
# Fix: Manually patch the global `mongo` object's connection with a `mongomock` client.
# This ensures all tests run against a fast, in-memory mock database AND
# are isolated from external services."
with app.app_context():
mongo.cx = mongomock.MongoClient()
mongo.db = mongo.cx[app.config["DB_NAME"]]

yield app


Expand Down Expand Up @@ -105,3 +116,21 @@ def db_setup(test_app): # pylint: disable=redefined-outer-name
with test_app.app_context():
collection = get_book_collection()
collection.delete_many({})


# Fixture for tests/test_auth.py
@pytest.fixture(scope="function")
def users_db_setup(test_app): # pylint: disable=redefined-outer-name
"""
Sets up and tears down the 'users' collection for a test.
"""
with test_app.app_context():
# Now, the 'mongo' variable is defined and linked to the test_app
users_collection = mongo.db.users
users_collection.delete_many({})

yield

with test_app.app_context():
users_collection = mongo.db.users
users_collection.delete_many({})
Loading