Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
d875416
Clean up comments
codesungrape Jul 22, 2025
13c783c
Format file with make format
codesungrape Jul 23, 2025
ecbcc76
Create book_service.py with fetch_active_books_cursor function
codesungrape Jul 23, 2025
f8e3c3c
Add find_books() to wrap collection.find and return a cursor
codesungrape Jul 23, 2025
71fb70a
Add API formatter for books fetched from MongoDB
codesungrape Jul 23, 2025
daa48bd
Refactor GET /books to use service layer helpers; format code
codesungrape Jul 23, 2025
ce9c2b1
Refactor fetch_active_books to return list; update callers
codesungrape Jul 23, 2025
f2a4181
Update test to mock new service layer functions
codesungrape Jul 23, 2025
993eace
Fix indent to correctly aggregate validation errors
codesungrape Jul 23, 2025
2ee43fa
Align book endpoint tests with service layer
codesungrape Jul 23, 2025
3ee2268
Fix $ MongoDB syntax; return None for consistency
codesungrape Jul 24, 2025
41f92a4
Fix format_books_for_api order: create id before validation
codesungrape Jul 24, 2025
47cdfc7
Replace in-memory GET books test with find_books mock and format check
codesungrape Jul 24, 2025
5162fb5
Apply formatting to mongo_helper and routes
codesungrape Jul 24, 2025
e6cf289
Update failin test with mock database patch
codesungrape Jul 24, 2025
be7c671
Handle database connection failures gracefully on GET /books
codesungrape Jul 24, 2025
0ec345d
Add tests for find_books db wrapper for 100% coverage
codesungrape Jul 24, 2025
7c01565
Update ConnectionFailure payload to reflect error schema
codesungrape Jul 24, 2025
c8f9cf7
Update openapi.yml GET to reflect 503 response
codesungrape Jul 24, 2025
cb9e4b6
Update app/datastore/mongo_helper.py
codesungrape Jul 24, 2025
bf41149
Update tests/test_app.py
codesungrape Jul 24, 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
4 changes: 2 additions & 2 deletions app/datastore/mongo_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@ def get_book_collection():
client = MongoClient(
current_app.config["MONGO_URI"], serverSelectionTimeoutMS=5000
)
# Check the status of the server, will fail if server is down

db = client[current_app.config["DB_NAME"]]
books_collection = db[current_app.config["COLLECTION_NAME"]]
return books_collection
except ConnectionFailure as e:
# Handle the connection error and return error information

raise ConnectionFailure(f"Could not connect to MongoDB: {str(e)}") from e
23 changes: 22 additions & 1 deletion app/datastore/mongo_helper.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Module containing pymongo helper functions."""

from pymongo.cursor import Cursor


def insert_book_to_mongo(book_data, collection):
"""
Expand All @@ -24,10 +26,29 @@ def insert_book_to_mongo(book_data, collection):
# - "If you DON'T find a document, INSERT the new data as a new document."
result = collection.replace_one(query_filter, book_data, upsert=True)

# 3. Check the result for logging/feedback.
# Check the result for logging/feedback.
if result.upserted_id:
print(f"✅ INSERTED new book with id: {result.upserted_id}")
elif result.modified_count > 0:
print(f"✅ REPLACED existing book with id: {book_data['id']}")

return result


def find_books(collection, query_filter=None, projection=None, limit=None) -> Cursor:
"""This acts as a wrapper around pymongo's collection.find() method.

Args:
collection: The pymongo collection object.
query_filter: The MongoDB query filter. Defaults to None (find all).
projection: The fields to include/exclude. Defaults to None (all fields).
limit: The maximum number of results to return. Defaults to None.

Returns:
A pymongo Cursor for the query results.
"""
query_filter = query_filter or {}
cursor = collection.find(query_filter, projection)
if limit is not None and limit > 0:
cursor = cursor.limit(limit)
return cursor
75 changes: 31 additions & 44 deletions app/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
import uuid

from flask import jsonify, request
from pymongo.errors import ConnectionFailure
from werkzeug.exceptions import HTTPException, NotFound

from app.datastore.mongo_db import get_book_collection
from app.datastore.mongo_helper import insert_book_to_mongo
from app.services.book_service import fetch_active_books, format_books_for_api
from app.utils.api_security import require_api_key
from app.utils.helper import append_hostname
from data import books
Expand All @@ -26,7 +28,7 @@ def register_routes(app): # pylint: disable=too-many-statements
@require_api_key
def add_book():
"""Function to add a new book to the collection."""
# check if request is json

if not request.is_json:
return jsonify({"error": "Request must be JSON"}), 415

Expand Down Expand Up @@ -65,9 +67,9 @@ def add_book():
if not isinstance(new_book[field], expected_type):
return {"error": f"Field {field} is not of type {expected_type}"}, 400

# use helper function
# establish connection to mongoDB
books_collection = get_book_collection()
# check if mongoDB connected??
# use mongoDB helper to insert/replace new book
insert_book_to_mongo(new_book, books_collection)

# Get the host from the request headers
Expand All @@ -89,45 +91,37 @@ def get_all_books():
return them in a JSON response
including the total count.
"""
if not books:
return jsonify({"error": "No books found"}), 404

all_books = []
# extract host from the request
host = request.host_url

for book in books:
# check if the book has the "deleted" state
if book.get("state") != "deleted":
# Remove state unless it's "deleted", then append
book_copy = copy.deepcopy(book)
book_copy.pop("state", None)
book_with_hostname = append_hostname(book_copy, host)
all_books.append(book_with_hostname)
try:
raw_books = fetch_active_books()
except ConnectionFailure:
error_payload = {
"error": {
"code": 503,
"name": "Service Unavailable",
"message": "The database service is temporarily unavailable.",
}
}

# validation
required_fields = ["id", "title", "synopsis", "author", "links"]
missing_fields_info = []
return jsonify(error_payload), 503

for book in all_books:
missing_fields = [field for field in required_fields if field not in book]
if missing_fields:
missing_fields_info.append(
{"book": book, "missing_fields": missing_fields}
)
if not raw_books:
return jsonify({"error": "No books found"}), 404

if missing_fields_info:
error_message = "Missing required fields:\n"
for info in missing_fields_info:
error_message += f"Missing fields: {', '.join(info['missing_fields'])} in {info['book']}. \n" # pylint: disable=line-too-long
# extract host from the request
host = request.host_url.rstrip("/")

print(error_message)
return jsonify({"error": error_message}), 500
all_formatted_books, error = format_books_for_api(raw_books, host)

count_books = len(all_books)
response_data = {"total_count": count_books, "items": all_books}
if error:
# Return HTTP error in controller layer
return jsonify({"error": error}), 500

return jsonify(response_data), 200
return (
jsonify(
{"total_count": len(all_formatted_books), "items": all_formatted_books}
),
200,
)

@app.route("/books/<string:book_id>", methods=["GET"])
def get_book(book_id):
Expand Down Expand Up @@ -219,7 +213,6 @@ def update_book(book_id):

return jsonify({"error": "Book not found"}), 404


# ----------- CUSTOM ERROR HANDLERS ------------------

@app.errorhandler(NotFound)
Expand All @@ -234,13 +227,7 @@ def handle_http_exception(e):
"""
# e.code is the HTTP status code (e.g. 401)
# e.description is the text you passed to abort()
response = {
"error": {
"code": e.code,
"name": e.name,
"message": e.description
}
}
response = {"error": {"code": e.code, "name": e.name, "message": e.description}}
return jsonify(response), e.code

@app.errorhandler(Exception)
Expand Down
68 changes: 68 additions & 0 deletions app/services/book_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""Service layer for handling book-related operations."""

from app.datastore.mongo_db import get_book_collection
from app.datastore.mongo_helper import find_books
from app.utils.helper import append_hostname


def fetch_active_books():
"""
Fetches a cursor for all non-deleted books from the database.
Returns a Python list.
"""

collection = get_book_collection()
query_filter = {"state": {"$ne": "deleted"}} # Only non-deleted books

cursor = find_books(collection, query_filter=query_filter)
return list(cursor)


def format_books_for_api(books, host_url):
"""
Process, validate, and format a list of raw book dicts for API response.
"""
# 1) PRE-PROCESS: Create a new list with the fields we will validate.
processed_books = []
for raw in books:
book = raw.copy()
# Create the 'id' field from '_id' for consistent validation.
if "_id" in book:
book["id"] = str(book["_id"])
processed_books.append(book)

required_fields = ["id", "title", "synopsis", "author", "links"]
missing_fields_info = []

# 2) Collect all validation errors
for raw in processed_books:
missing = [f for f in required_fields if f not in raw]
if missing:
missing_fields_info.append({"book": raw, "missing_fields": missing})

# 3) If any errors, build error message and return
if missing_fields_info:
msg_lines_list = ["Missing required fields:"]

# In a loop, add new strings to the list
for info in missing_fields_info:
fields = ", ".join(info["missing_fields"])
msg_lines_list.append(f"- {fields} in book: {info['book']}")

# Join all the strings in the list ONCE at the end
error_message = "\n".join(msg_lines_list)

# 4. Return the final string
return None, error_message

# FORMAT: Remove fields not meant for the public API
formatted_list = []
for book in processed_books:
book.pop("_id", None)
book.pop("state", None)

# Call the helper with the host_url it needs
book_with_hostname = append_hostname(book, host_url)
formatted_list.append(book_with_hostname)

return formatted_list, None
10 changes: 9 additions & 1 deletion openapi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,12 @@ components:
application/json:
schema:
$ref: '#/components/schemas/StandardError'
ServiceUnavailable:
description: The service is temporarily unavailable, likely due to a database connection issue or server maintenance.
content:
application/json:
schema:
$ref: '#/components/schemas/StandardError'
InternalServerError:
description: An unexpected error occurred on the server.
content:
Expand Down Expand Up @@ -230,7 +236,9 @@ paths:
'404':
$ref: '#/components/responses/NotFound'
'500':
$ref: '#/components/responses/InternalServerError'
$ref: '#/components/responses/InternalServerError'
'503':
$ref: '#/components/responses/ServiceUnavailable'
# --------------------------------------------
/books/{book_id}:
# --------------------------------------------
Expand Down
6 changes: 3 additions & 3 deletions tests/test_api_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,6 @@ def test_add_book_fails_with_missing_key(client, monkeypatch):
)
monkeypatch.setattr("app.routes.append_hostname", lambda book, host: book)


# Hit the endpoint without Authorization header
response = client.post("/books", json=DUMMY_PAYLOAD)

Expand Down Expand Up @@ -114,7 +113,6 @@ def test_update_book_succeeds_with_valid_api_key(monkeypatch, client):
# 2. Patch append_hostname to just return the book
monkeypatch.setattr("app.routes.append_hostname", lambda book, host: book)


# 3. Call the endpoint with request details
response = client.put("/books/abc123", json=DUMMY_PAYLOAD, headers=HEADERS["VALID"])

Expand All @@ -137,7 +135,9 @@ def test_update_book_fails_with_missing_api_key(monkeypatch, client):
def test_update_book_fails_with_invalid_api_key(client, monkeypatch):
monkeypatch.setattr("app.routes.books", [])

response = client.put("/books/abc123", json=DUMMY_PAYLOAD, headers=HEADERS["INVALID"])
response = client.put(
"/books/abc123", json=DUMMY_PAYLOAD, headers=HEADERS["INVALID"]
)
# ASSERT: Verify the server rejected the request as expected.
assert response.status_code == 401
assert "Invalid API key." in response.json["error"]["message"]
Expand Down
Loading