Skip to content

Commit 7df5893

Browse files
authored
Add /auth/register endpoint for jwt route protection (#11)
- Add Flask-PyMongo, extension.py and app factory init - Add users_db_setup fixture to clean users collection - Add register and duplicate-email tests, and invalid-email tests - Add app/routes directory; rename and move app/routes.py to app/routes/legacy_routes.py - Use Blueprints for auth_routes; register new blueprint - Add email validation (email-validator), normalize email, bcrypt hashing - Add error handling improvements - Run formatting, update requirements and openapi.yml Co-authored-by: Copilot <[email protected]>"
1 parent 1747121 commit 7df5893

16 files changed

+503
-78
lines changed

app/__init__.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,25 +3,32 @@
33
import os
44

55
from flask import Flask
6+
from flask_pymongo import PyMongo
67

78
from app.config import Config
9+
from app.extensions import mongo
810

911

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

1315
app = Flask(__name__)
14-
15-
# 1. Load the default configuration from the Config object.
1616
app.config.from_object(Config)
1717

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

21-
# Import routes
22-
from app.routes import \
23-
register_routes # pylint: disable=import-outside-toplevel
21+
# Connect Pymongo to our specific app instance
22+
mongo.init_app(app)
23+
24+
# Import blueprints inside the factory
25+
from app.routes.auth_routes import \
26+
auth_bp # pylint: disable=import-outside-toplevel
27+
from app.routes.legacy_routes import \
28+
register_legacy_routes # pylint: disable=import-outside-toplevel
2429

25-
register_routes(app)
30+
# Register routes with app instance
31+
register_legacy_routes(app)
32+
app.register_blueprint(auth_bp)
2633

2734
return app

app/config.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
# pylint: disable=too-few-public-methods
22

33
"""
4-
Application configuration module for Flask.
4+
The central, organized place for the application's settings.
5+
6+
Loads environment variables (and other sensitive values) from a .env file and
7+
Defines the Config class to be used.
58
6-
Loads environment variables from a .env file and defines the Config class
7-
used to configure Flask and database connection settings.
89
"""
910

1011
import os

app/datastore/mongo_helper.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
"""Module containing pymongo helper functions."""
22

33
from bson.objectid import InvalidId, ObjectId
4-
from pymongo.cursor import Cursor
54
from pymongo.collection import Collection
5+
from pymongo.cursor import Cursor
66

77

88
def insert_book_to_mongo(book_data, collection):
@@ -83,8 +83,10 @@ def delete_book_by_id(book_collection: Collection, book_id: str):
8383

8484
return result
8585

86+
8687
# ------ PUT helpers ------------
8788

89+
8890
def validate_book_put_payload(payload: dict):
8991
"""
9092
Validates the payload for a PUT request.

app/extensions.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""Module for Flask extensions."""
2+
3+
from flask_pymongo import PyMongo
4+
5+
# Createempty PyMongo extension object globally
6+
# This way, we can import it in other files and avoid a code smell: tighly-coupled, cyclic error
7+
mongo = PyMongo()

app/routes/__init__.py

Whitespace-only changes.

app/routes/auth_routes.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# pylint: disable=cyclic-import
2+
"""Routes for authorization for the JWT upgrade"""
3+
4+
import bcrypt
5+
from email_validator import EmailNotValidError, validate_email
6+
from flask import Blueprint, jsonify, request
7+
from werkzeug.exceptions import BadRequest
8+
9+
from app.extensions import mongo
10+
11+
auth_bp = Blueprint("auth_bp", __name__, url_prefix="/auth")
12+
13+
14+
@auth_bp.route("/register", methods=["POST"])
15+
def register_user():
16+
"""
17+
Registers a new user.
18+
Takes a JSON payload with "email" and "password".
19+
It verfies it is not a duplicate email,
20+
Hashes the password and stores the new user in the database.
21+
"""
22+
23+
# VALIDATION the incoming data/request payload
24+
try:
25+
data = request.get_json()
26+
if not data:
27+
return jsonify({"message": "Request body cannot be empty"}), 400
28+
29+
email = data.get("email")
30+
password = data.get("password")
31+
32+
if not email or not password:
33+
return jsonify({"message": "Email and password are required"}), 400
34+
35+
# email-validator
36+
try:
37+
valid = validate_email(email, check_deliverability=False)
38+
39+
# use the normalized email for all subsequent operations
40+
email = valid.normalized
41+
except EmailNotValidError as e:
42+
return jsonify({"message": str(e)}), 400
43+
44+
except BadRequest:
45+
return jsonify({"message": "Invalid JSON format"}), 400
46+
47+
# Check for Duplicate User
48+
# Easy access with Flask_PyMongo's 'mongo'
49+
if mongo.db.users.find_one({"email": email}):
50+
return jsonify({"message": "Email is already registered"}), 409
51+
52+
# Password Hashing
53+
# Generate a salt and hash the password
54+
# result is a byte object representing the final hash
55+
hashed_password = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt())
56+
57+
# Database Insertion
58+
user_id = mongo.db.users.insert_one(
59+
{
60+
"email": email,
61+
# The hash is stored as a string in the DB
62+
"password_hash": hashed_password.decode("utf-8"),
63+
}
64+
).inserted_id
65+
print(user_id)
66+
67+
# Prepare response
68+
return (
69+
jsonify(
70+
{
71+
"message": "User registered successfully",
72+
"user": {"id": str(user_id), "email": email},
73+
}
74+
),
75+
201,
76+
)

app/routes.py renamed to app/routes/legacy_routes.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from app.utils.helper import append_hostname
1616

1717

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

openapi.yml

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ tags:
2828
description: Find out more
2929
url: example.com
3030

31+
- name: Authentication
32+
description: Operations related to user registration and login
33+
3134
# --------------------------------------------
3235
# Components
3336
components:
@@ -108,6 +111,57 @@ components:
108111
type: array
109112
items:
110113
$ref: '#/components/schemas/BookOutput'
114+
115+
# ----- AUTH schemas -------------
116+
117+
# Schema for the data client POSTs to register a user
118+
UserRegistrationInput:
119+
type: object
120+
properties:
121+
email:
122+
type: string
123+
format: email
124+
description: The user's email address.
125+
example: "[email protected]"
126+
password:
127+
type: string
128+
format: password # This format is a hint for UI tools to obscure the input
129+
description: The user's desired password.
130+
minLength: 8 # It's good practice to suggest a minimum length
131+
example: "a-very-secure-password"
132+
required:
133+
- email
134+
- password
135+
136+
# Schema for the User object as returned by the server on success
137+
UserOutput:
138+
type: object
139+
properties:
140+
id:
141+
type: string
142+
description: The unique 24-character hexadecimal identifier for the user (MongoDB ObjectId).
143+
readOnly: true
144+
example: "635f3a7e3a8e3bcfc8e6a1e0"
145+
email:
146+
type: string
147+
format: email
148+
readOnly: true
149+
example: "[email protected]"
150+
151+
# Schema for the successful registration response
152+
RegistrationSuccess:
153+
type: object
154+
properties:
155+
message:
156+
type: string
157+
example: "User registered successfully"
158+
user:
159+
$ref: '#/components/schemas/UserOutput'
160+
161+
162+
163+
164+
# ------ ERROR schemas ----------
111165

112166
# Generic Error schema
113167
StandardError:
@@ -144,6 +198,16 @@ components:
144198
required:
145199
- error
146200

201+
# Schema for a simple message response (useful for errors)
202+
MessageError:
203+
type: object
204+
properties:
205+
message:
206+
type: string
207+
description: A brief error message.
208+
required:
209+
- message
210+
147211
# API Error: Reusable responses for common errors
148212
responses:
149213
BadRequest:
@@ -426,3 +490,50 @@ paths:
426490
error: "Book not found"
427491
'500':
428492
$ref: '#/components/responses/InternalServerError'
493+
# --------------------------------------------
494+
/auth/register:
495+
# --------------------------------------------
496+
post:
497+
tags:
498+
- Authentication
499+
summary: Register a new user
500+
description: >-
501+
Creates a new user account. The server will hash the password and store the user details, returning a unique ID for the new user.
502+
operationId: registerUser
503+
requestBody:
504+
description: User's email and password for registration.
505+
required: true
506+
content:
507+
application/json:
508+
schema:
509+
$ref: '#/components/schemas/UserRegistrationInput'
510+
responses:
511+
'201':
512+
description: User registered successfully.
513+
content:
514+
application/json:
515+
schema:
516+
$ref: '#/components/schemas/RegistrationSuccess'
517+
'400':
518+
description: Bad Request. The request body is missing, not valid JSON, or is missing required fields.
519+
content:
520+
application/json:
521+
schema:
522+
$ref: '#/components/schemas/MessageError'
523+
examples:
524+
missingFields:
525+
summary: Missing Fields
526+
value:
527+
message: "Email and password are required"
528+
emptyBody:
529+
summary: Empty Body
530+
value:
531+
message: "Request body cannot be empty"
532+
'409':
533+
description: Conflict. The provided email is already registered.
534+
content:
535+
application/json:
536+
schema:
537+
$ref: '#/components/schemas/MessageError' # Reuse the error schema again!
538+
example:
539+
message: "Email is already registered"

requirements.txt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,7 @@ pymongo
77
python-dotenv
88
mongomock
99
black
10-
isort
10+
isort
11+
flask_pymongo
12+
flask-bcrypt
13+
email-validator

tests/conftest.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,15 @@
99
import mongomock
1010
import pytest
1111

12-
from app import create_app
12+
from app import create_app, mongo
1313
from app.datastore.mongo_db import get_book_collection
1414

1515

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

20-
with patch("app.routes.insert_book_to_mongo") as mock_insert_book:
20+
with patch("app.routes.legacy_routes.insert_book_to_mongo") as mock_insert_book:
2121
mock_insert_book.return_value.inserted_id = "12345"
2222
yield mock_insert_book
2323

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

8394

@@ -105,3 +116,21 @@ def db_setup(test_app): # pylint: disable=redefined-outer-name
105116
with test_app.app_context():
106117
collection = get_book_collection()
107118
collection.delete_many({})
119+
120+
121+
# Fixture for tests/test_auth.py
122+
@pytest.fixture(scope="function")
123+
def users_db_setup(test_app): # pylint: disable=redefined-outer-name
124+
"""
125+
Sets up and tears down the 'users' collection for a test.
126+
"""
127+
with test_app.app_context():
128+
# Now, the 'mongo' variable is defined and linked to the test_app
129+
users_collection = mongo.db.users
130+
users_collection.delete_many({})
131+
132+
yield
133+
134+
with test_app.app_context():
135+
users_collection = mongo.db.users
136+
users_collection.delete_many({})

0 commit comments

Comments
 (0)