From 159d610973f616393d38d6a067fffdc5d22534fe Mon Sep 17 00:00:00 2001 From: codesungrape Date: Thu, 19 Jun 2025 16:35:46 +0100 Subject: [PATCH 01/16] Add init.py to initialize utils/ and move append_hostname there --- app.py | 10 +--------- utils/__init__.py | 0 utils/helper.py | 12 ++++++++++++ 3 files changed, 13 insertions(+), 9 deletions(-) create mode 100644 utils/__init__.py create mode 100644 utils/helper.py diff --git a/app.py b/app.py index 29ff037..ff5ce7d 100644 --- a/app.py +++ b/app.py @@ -2,13 +2,13 @@ import uuid import copy import os -from urllib.parse import urljoin from dotenv import load_dotenv from flask import Flask, request, jsonify from werkzeug.exceptions import NotFound from pymongo import MongoClient from pymongo.errors import ConnectionFailure from mongo_helper import insert_book_to_mongo +from utils.helper import append_hostname from data import books app = Flask(__name__) @@ -32,14 +32,6 @@ def get_book_collection(): # Handle the connection error and return error information raise ConnectionFailure(f'Could not connect to MongoDB: {str(e)}') from e -def append_hostname(book, host): - """Helper function to append the hostname to the links in a book object.""" - if "links" in book: - book["links"] = { - key: urljoin(host, path) - for key, path in book["links"].items() - } - return book # ----------- POST section ------------------ diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utils/helper.py b/utils/helper.py new file mode 100644 index 0000000..a08d6c2 --- /dev/null +++ b/utils/helper.py @@ -0,0 +1,12 @@ +""" Collection of helper fucntions """ + +from urllib.parse import urljoin + +def append_hostname(book, host): + """Helper function to append the hostname to the links in a book object.""" + if "links" in book: + book["links"] = { + key: urljoin(host, path) + for key, path in book["links"].items() + } + return book From 9a7c90f0085f719038e1f61043138aa093a071a0 Mon Sep 17 00:00:00 2001 From: codesungrape Date: Thu, 19 Jun 2025 17:55:26 +0100 Subject: [PATCH 02/16] Move MongoDB connection logic to datastore.mongo_db module --- app.py | 17 +---------------- datastore/__init__.py | 0 datastore/mongo_db.py | 19 +++++++++++++++++++ 3 files changed, 20 insertions(+), 16 deletions(-) create mode 100644 datastore/__init__.py create mode 100644 datastore/mongo_db.py diff --git a/app.py b/app.py index ff5ce7d..e9d7913 100644 --- a/app.py +++ b/app.py @@ -5,10 +5,9 @@ from dotenv import load_dotenv from flask import Flask, request, jsonify from werkzeug.exceptions import NotFound -from pymongo import MongoClient -from pymongo.errors import ConnectionFailure from mongo_helper import insert_book_to_mongo from utils.helper import append_hostname +from datastore.mongo_db import get_book_collection from data import books app = Flask(__name__) @@ -19,20 +18,6 @@ app.config['DB_NAME'] = os.getenv('PROJECT_DATABASE') app.config['COLLECTION_NAME'] = os.getenv('PROJECT_COLLECTION') -def get_book_collection(): - """Initialize the mongoDB connection""" - try: - client = MongoClient(app.config['MONGO_URI'], serverSelectionTimeoutMS=5000) - # Check the status of the server, will fail if server is down - # client.admin.command('ismaster') - db = client[app.config['DB_NAME']] - books_collection = db[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 - - # ----------- POST section ------------------ @app.route("/books", methods=["POST"]) diff --git a/datastore/__init__.py b/datastore/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/datastore/mongo_db.py b/datastore/mongo_db.py new file mode 100644 index 0000000..c2a583b --- /dev/null +++ b/datastore/mongo_db.py @@ -0,0 +1,19 @@ +from flask import current_app +from pymongo import MongoClient +from pymongo.errors import ConnectionFailure + + +def get_book_collection(): + """ + Initialize the mongoDB connection + Use current_app to get global flask instance context + """ + try: + 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 \ No newline at end of file From d5a287c5c9db79ed1c6d44414fdb88a1712bc98a Mon Sep 17 00:00:00 2001 From: codesungrape Date: Thu, 19 Jun 2025 17:57:53 +0100 Subject: [PATCH 03/16] Fix connection failure test by adding app context --- tests/test_app.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/test_app.py b/tests/test_app.py index aca2295..4ced83c 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -18,6 +18,7 @@ def stub_insert_book(): mock_insert_book.return_value.inserted_id = "12345" yield mock_insert_book + # Mock book database object books_database = [ @@ -551,11 +552,12 @@ def test_append_host_to_links_in_put(client): f"Link should end with the resource path '{expected_path}'" def test_get_book_collection_handles_connection_failure(): - with patch("app.MongoClient") as mock_client: + with patch("datastore.mongo_db.MongoClient") as mock_client: # Set the side effect to raise a ServerSelectionTimeoutError mock_client.side_effect = ServerSelectionTimeoutError("Mock Connection Timeout") - with pytest.raises(Exception) as exc_info: - get_book_collection() + with app.app_context(): # <-- Push the app context here + with pytest.raises(Exception) as exc_info: + get_book_collection() assert "Could not connect to MongoDB: Mock Connection Timeout" in str(exc_info.value) From 769d3781e140e812639b80218e58e7806d1b7a31 Mon Sep 17 00:00:00 2001 From: codesungrape Date: Fri, 20 Jun 2025 13:17:08 +0100 Subject: [PATCH 04/16] Move mongo_helper.py to datastore/; update import paths --- app.py | 2 +- datastore/mongo_db.py | 3 ++- mongo_helper.py => datastore/mongo_helper.py | 0 tests/test_mongo_helper.py | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) rename mongo_helper.py => datastore/mongo_helper.py (100%) diff --git a/app.py b/app.py index e9d7913..7e33682 100644 --- a/app.py +++ b/app.py @@ -5,7 +5,7 @@ from dotenv import load_dotenv from flask import Flask, request, jsonify from werkzeug.exceptions import NotFound -from mongo_helper import insert_book_to_mongo +from datastore.mongo_helper import insert_book_to_mongo from utils.helper import append_hostname from datastore.mongo_db import get_book_collection from data import books diff --git a/datastore/mongo_db.py b/datastore/mongo_db.py index c2a583b..95d19c7 100644 --- a/datastore/mongo_db.py +++ b/datastore/mongo_db.py @@ -1,3 +1,4 @@ +"""Utility functions to interact with a MongoDB collection""" from flask import current_app from pymongo import MongoClient from pymongo.errors import ConnectionFailure @@ -16,4 +17,4 @@ def get_book_collection(): 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 \ No newline at end of file + raise ConnectionFailure(f'Could not connect to MongoDB: {str(e)}') from e diff --git a/mongo_helper.py b/datastore/mongo_helper.py similarity index 100% rename from mongo_helper.py rename to datastore/mongo_helper.py diff --git a/tests/test_mongo_helper.py b/tests/test_mongo_helper.py index 80ba261..9fa1706 100644 --- a/tests/test_mongo_helper.py +++ b/tests/test_mongo_helper.py @@ -1,6 +1,6 @@ # pylint: disable=missing-docstring from unittest.mock import MagicMock -from mongo_helper import insert_book_to_mongo +from datastore.mongo_helper import insert_book_to_mongo # @patch('mongo_helper.books_collection') def test_insert_book_to_mongo(): From ac8269f751c0b3d3c91e4f52c7d95fe5feeec736 Mon Sep 17 00:00:00 2001 From: codesungrape Date: Fri, 20 Jun 2025 16:29:15 +0100 Subject: [PATCH 05/16] Restructure app with app/__init__.py, routes module, avoid circulars --- app/__init__.py | 18 ++++ app/routes.py | 218 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 236 insertions(+) create mode 100644 app/__init__.py create mode 100644 app/routes.py diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..3b15476 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,18 @@ +import os +from dotenv import load_dotenv +from flask import Flask + +app = Flask(__name__) + +# Use app.config to set config connection details +load_dotenv() +app.config['MONGO_URI'] = os.getenv('MONGO_CONNECTION') +app.config['DB_NAME'] = os.getenv('PROJECT_DATABASE') +app.config['COLLECTION_NAME'] = os.getenv('PROJECT_COLLECTION') + +# Import routes — routes can import app safely because it exists +from app.routes import register_routes +register_routes(app) + +# Expose `app` for importing +__all__ = ["app"] diff --git a/app/routes.py b/app/routes.py new file mode 100644 index 0000000..0d02f81 --- /dev/null +++ b/app/routes.py @@ -0,0 +1,218 @@ +"""Flask application module for managing a collection of books.""" +import uuid +import copy +# import os +# from dotenv import load_dotenv +from flask import request, jsonify +from werkzeug.exceptions import NotFound +from datastore.mongo_helper import insert_book_to_mongo +from utils.helper import append_hostname +from datastore.mongo_db import get_book_collection +from data import books + + + +# ----------- POST section ------------------ +def register_routes(app): + @app.route("/books", methods=["POST"]) + 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 + + new_book = request.json + if not isinstance(new_book, dict): + return jsonify({"error": "JSON payload must be a dictionary"}), 400 + + # create UUID and add it to the new_book object + new_book_id = str(uuid.uuid4()) + new_book["id"] = new_book_id + + # validation + required_fields = ["title", "synopsis", "author"] + missing_fields = [field for field in required_fields if field not in new_book] + if missing_fields: + return {"error": f"Missing required fields: {', '.join(missing_fields)}"}, 400 + + new_book['links'] = { + "self": f"/books/{new_book_id}", + "reservations": f"/books/{new_book_id}/reservations", + "reviews": f"/books/{new_book_id}/reviews" + } + + # Map field names to their expected types + field_types = { + "id": str, + "title": str, + "synopsis": str, + "author": str, + "links": dict + } + + for field, expected_type in field_types.items(): + if not isinstance(new_book[field], expected_type): + return {"error": f"Field {field} is not of type {expected_type}"}, 400 + + # use helper function + books_collection = get_book_collection() + # check if mongoDB connected?? + insert_book_to_mongo(new_book, books_collection) + + # Get the host from the request headers + host = request.host_url + # Send the host and new book_id to the helper function to generate links + book_for_response = append_hostname(new_book, host) + print("book_for_response", book_for_response) + # Remove MOngoDB's ObjectID value + book_for_response.pop('_id', None) + + return jsonify(book_for_response), 201 + + + # ----------- GET section ------------------ + @app.route("/books", methods=["GET"]) + def get_all_books(): + """ + Retrieve all books from the database and + 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": + # if the book has a state other than "deleted" remove the state field before appending + book_copy = copy.deepcopy(book) + book_copy.pop("state", None) + book_with_hostname = append_hostname(book_copy, host) + all_books.append(book_with_hostname) + + # validation + required_fields = ["id", "title", "synopsis", "author", "links"] + missing_fields_info = [] + + 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 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 + + print(error_message) + return jsonify({"error": error_message}), 500 + + count_books = len(all_books) + response_data = { + "total_count" : count_books, + "items" : all_books + } + + return jsonify(response_data), 200 + + @app.route("/books/", methods=["GET"]) + def get_book(book_id): + """ + Retrieve a specific book by its unique ID. + """ + if not books: + return jsonify({"error": "Book collection not initialized"}), 500 + + # extract host from the request + host = request.host_url + + for book in books: + if book.get("id") == book_id and book.get("state") != "deleted": + # copy the book + book_copy = copy.deepcopy(book) + book_copy.pop("state", None) + # Add the hostname to the book_copy object and return it + return jsonify(append_hostname(book_copy, host)), 200 + return jsonify({"error": "Book not found"}), 404 + + + # ----------- DELETE section ------------------ + @app.route("/books/", methods=["DELETE"]) + def delete_book(book_id): + """ + Soft delete a book by setting its state to 'deleted' or return error if not found. + """ + if not books: + return jsonify({"error": "Book collection not initialized"}), 500 + + for book in books: + if book.get("id") == book_id: + book["state"] = "deleted" + return "", 204 + return jsonify({"error": "Book not found"}), 404 + + # ----------- PUT section ------------------ + + @app.route("/books/", methods=["PUT"]) + def update_book(book_id): + """ + Update a book by its unique ID using JSON from the request body. + Returns a single dictionary with the updated book's details. + """ + if not books: + return jsonify({"error": "Book collection not initialized"}), 500 + + # check if request is json + if not request.is_json: + return jsonify({"error": "Request must be JSON"}), 415 + + # check request body is valid + request_body = request.get_json() + if not isinstance(request_body, dict): + return jsonify({"error": "JSON payload must be a dictionary"}), 400 + + # check request body contains required fields + required_fields = ["title", "synopsis", "author"] + missing_fields = [field for field in required_fields if field not in request_body] + if missing_fields: + return {"error": f"Missing required fields: {', '.join(missing_fields)}"}, 400 + + host = request.host_url + + # now that we have a book object that is valid, loop through books + for book in books: + if book.get("id") == book_id: + # update the book values to what is in the request + book["title"] = request.json.get("title") + book["synopsis"] = request.json.get("synopsis") + book["author"] = request.json.get("author") + + # Add links exists as paths only + book["links"] = { + "self": f"/books/{book_id}", + "reservations": f"/books/{book_id}/reservations", + "reviews": f"/books/{book_id}/reviews" + } + # make a deepcopy of the modified book + book_copy = copy.deepcopy(book) + book_with_hostname = append_hostname(book_copy, host) + return jsonify(book_with_hostname), 200 + + return jsonify({"error": "Book not found"}), 404 + + @app.errorhandler(NotFound) + def handle_not_found(e): + """Return a custom JSON response for 404 Not Found errors.""" + return jsonify({"error": str(e)}), 404 + + @app.errorhandler(Exception) + def handle_exception(e): + """Return a custom JSON response for any exception.""" + return jsonify({"error": str(e)}), 500 From 6e7f4741710c9f974b3c6e9fda809a31c05f007a Mon Sep 17 00:00:00 2001 From: codesungrape Date: Fri, 20 Jun 2025 16:30:31 +0100 Subject: [PATCH 06/16] Fix tests to import app from package and patch updated paths --- tests/test_app.py | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/tests/test_app.py b/tests/test_app.py index 4ced83c..42412b3 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -2,7 +2,9 @@ from unittest.mock import patch from pymongo.errors import ServerSelectionTimeoutError import pytest -from app import app, get_book_collection +from datastore.mongo_db import get_book_collection +from app import app + # Option 1: Rename the fixture to something unique (which I've used) # Option 2: Use a linter plugin that understands pytest @@ -14,7 +16,7 @@ def client_fixture(): # Create a stub to mock the insert_book_to_mongo function to avoid inserting to real DB @pytest.fixture(name="_insert_book_to_db") def stub_insert_book(): - with patch("app.insert_book_to_mongo") as mock_insert_book: + with patch("app.routes.insert_book_to_mongo") as mock_insert_book: mock_insert_book.return_value.inserted_id = "12345" yield mock_insert_book @@ -93,7 +95,6 @@ def test_add_book_sent_with_missing_required_fields(client): assert 'error' in response_data assert "Missing required fields: title, synopsis" in response.get_json()["error"] - def test_add_book_sent_with_wrong_types(client): test_book = { "title": 1234567, @@ -156,19 +157,19 @@ def test_get_all_books_returns_all_books(client): assert 'items' in response_data def test_return_error_404_when_list_is_empty(client): - with patch("app.books", []): + with patch("app.routes.books", []): response = client.get("/books") assert response.status_code == 404 assert "No books found" in response.get_json()["error"] def test_get_books_returns_404_when_books_is_none(client): - with patch("app.books", None): + with patch("app.routes.books", None): response = client.get("/books") assert response.status_code == 404 assert "No books found" in response.get_json()["error"] def test_missing_fields_in_book_object_returned_by_database(client): - with patch("app.books", [ + with patch("app.routes.books", [ { "id": "1", "title": "The Great Adventure", @@ -191,9 +192,10 @@ def test_missing_fields_in_book_object_returned_by_database(client): #-------- Tests for filter GET /books by delete ---------------- + def test_get_books_excludes_deleted_books_and_omits_state_field(client): # Add a book so we have a known ID - with patch("app.books", [ + with patch("app.routes.books", [ { "id": "1", "title": "The Great Adventure", @@ -270,7 +272,7 @@ def test_invalid_urls_return_404(client): assert "404 Not Found" in response.get_json()["error"] def test_book_database_is_initialized_for_specific_book_route(client): - with patch("app.books", None): + with patch("app.routes.books", None): response = client.get("/books/1") assert response.status_code == 500 assert "Book collection not initialized" in response.get_json()["error"] @@ -285,7 +287,7 @@ def test_get_book_returns_404_if_state_equals_deleted(client): # ------------------------ Tests for DELETE -------------------------------------------- def test_book_is_soft_deleted_on_delete_request(client): - with patch("app.books", books_database): + with patch("app.routes.books", books_database): # Send DELETE request book_id = '1' response = client.delete(f"/books/{book_id}") @@ -309,7 +311,7 @@ def test_delete_invalid_book_id(client): assert "Book not found" in response.get_json()["error"] def test_book_database_is_initialized_for_delete_book_route(client): - with patch("app.books", None): + with patch("app.routes.books", None): response = client.delete("/books/1") assert response.status_code == 500 assert "Book collection not initialized" in response.get_json()["error"] @@ -317,7 +319,7 @@ def test_book_database_is_initialized_for_delete_book_route(client): # ------------------------ Tests for PUT -------------------------------------------- def test_update_book_request_returns_correct_status_and_content_type(client): - with patch("app.books", books_database): + with patch("app.routes.books", books_database): test_book = { "title": "Test Book", @@ -333,7 +335,7 @@ def test_update_book_request_returns_correct_status_and_content_type(client): assert response.content_type == "application/json" def test_update_book_request_returns_required_fields(client): - with patch("app.books", books_database): + with patch("app.routes.books", books_database): test_book = { "title": "Test Book", "author": "AN Other", @@ -362,7 +364,7 @@ def test_update_book_replaces_whole_object(client): } } # Patch the books list with just this book (no links) - with patch("app.books", [book_to_be_changed]): + with patch("app.routes.books", [book_to_be_changed]): updated_data = { "title": "Updated Title", "author": "Updated Author", @@ -384,7 +386,7 @@ def test_update_book_replaces_whole_object(client): assert data["synopsis"] == "Updated Synopsis" def test_update_book_sent_with_invalid_book_id(client): - with patch("app.books", books_database): + with patch("app.routes.books", books_database): test_book = { "title": "Some title", "author": "Some author", @@ -395,7 +397,7 @@ def test_update_book_sent_with_invalid_book_id(client): assert "Book not found" in response.get_json()["error"] def test_book_database_is_initialized_for_update_book_route(client): - with patch("app.books", None): + with patch("app.routes.books", None): test_book = { "title": "Test Book", "author": "AN Other", From b64bd48f09388fac7f640b494cbd00e96447739b Mon Sep 17 00:00:00 2001 From: codesungrape Date: Fri, 20 Jun 2025 16:44:18 +0100 Subject: [PATCH 07/16] Tweak code to resolve pylint warnings and disable select rules --- app.py | 224 ------------------------------------------------ app/__init__.py | 4 +- app/routes.py | 18 ++-- 3 files changed, 15 insertions(+), 231 deletions(-) delete mode 100644 app.py diff --git a/app.py b/app.py deleted file mode 100644 index 7e33682..0000000 --- a/app.py +++ /dev/null @@ -1,224 +0,0 @@ -"""Flask application module for managing a collection of books.""" -import uuid -import copy -import os -from dotenv import load_dotenv -from flask import Flask, request, jsonify -from werkzeug.exceptions import NotFound -from datastore.mongo_helper import insert_book_to_mongo -from utils.helper import append_hostname -from datastore.mongo_db import get_book_collection -from data import books - -app = Flask(__name__) - -# Use app.config to set config connection details -load_dotenv() -app.config['MONGO_URI'] = os.getenv('MONGO_CONNECTION') -app.config['DB_NAME'] = os.getenv('PROJECT_DATABASE') -app.config['COLLECTION_NAME'] = os.getenv('PROJECT_COLLECTION') - - -# ----------- POST section ------------------ -@app.route("/books", methods=["POST"]) -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 - - new_book = request.json - if not isinstance(new_book, dict): - return jsonify({"error": "JSON payload must be a dictionary"}), 400 - - # create UUID and add it to the new_book object - new_book_id = str(uuid.uuid4()) - new_book["id"] = new_book_id - - # validation - required_fields = ["title", "synopsis", "author"] - missing_fields = [field for field in required_fields if field not in new_book] - if missing_fields: - return {"error": f"Missing required fields: {', '.join(missing_fields)}"}, 400 - - new_book['links'] = { - "self": f"/books/{new_book_id}", - "reservations": f"/books/{new_book_id}/reservations", - "reviews": f"/books/{new_book_id}/reviews" - } - - # Map field names to their expected types - field_types = { - "id": str, - "title": str, - "synopsis": str, - "author": str, - "links": dict - } - - for field, expected_type in field_types.items(): - if not isinstance(new_book[field], expected_type): - return {"error": f"Field {field} is not of type {expected_type}"}, 400 - - # use helper function - books_collection = get_book_collection() - # check if mongoDB connected?? - insert_book_to_mongo(new_book, books_collection) - - # Get the host from the request headers - host = request.host_url - # Send the host and new book_id to the helper function to generate links - book_for_response = append_hostname(new_book, host) - print("book_for_response", book_for_response) - # Remove MOngoDB's ObjectID value - book_for_response.pop('_id', None) - - return jsonify(book_for_response), 201 - - -# ----------- GET section ------------------ -@app.route("/books", methods=["GET"]) -def get_all_books(): - """ - Retrieve all books from the database and - 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": - # if the book has a state other than "deleted" remove the state field before appending - book_copy = copy.deepcopy(book) - book_copy.pop("state", None) - book_with_hostname = append_hostname(book_copy, host) - all_books.append(book_with_hostname) - - # validation - required_fields = ["id", "title", "synopsis", "author", "links"] - missing_fields_info = [] - - 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 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 - - print(error_message) - return jsonify({"error": error_message}), 500 - - count_books = len(all_books) - response_data = { - "total_count" : count_books, - "items" : all_books - } - - return jsonify(response_data), 200 - -@app.route("/books/", methods=["GET"]) -def get_book(book_id): - """ - Retrieve a specific book by its unique ID. - """ - if not books: - return jsonify({"error": "Book collection not initialized"}), 500 - - # extract host from the request - host = request.host_url - - for book in books: - if book.get("id") == book_id and book.get("state") != "deleted": - # copy the book - book_copy = copy.deepcopy(book) - book_copy.pop("state", None) - # Add the hostname to the book_copy object and return it - return jsonify(append_hostname(book_copy, host)), 200 - return jsonify({"error": "Book not found"}), 404 - - -# ----------- DELETE section ------------------ -@app.route("/books/", methods=["DELETE"]) -def delete_book(book_id): - """ - Soft delete a book by setting its state to 'deleted' or return error if not found. - """ - if not books: - return jsonify({"error": "Book collection not initialized"}), 500 - - for book in books: - if book.get("id") == book_id: - book["state"] = "deleted" - return "", 204 - return jsonify({"error": "Book not found"}), 404 - -# ----------- PUT section ------------------ - -@app.route("/books/", methods=["PUT"]) -def update_book(book_id): - """ - Update a book by its unique ID using JSON from the request body. - Returns a single dictionary with the updated book's details. - """ - if not books: - return jsonify({"error": "Book collection not initialized"}), 500 - - # check if request is json - if not request.is_json: - return jsonify({"error": "Request must be JSON"}), 415 - - # check request body is valid - request_body = request.get_json() - if not isinstance(request_body, dict): - return jsonify({"error": "JSON payload must be a dictionary"}), 400 - - # check request body contains required fields - required_fields = ["title", "synopsis", "author"] - missing_fields = [field for field in required_fields if field not in request_body] - if missing_fields: - return {"error": f"Missing required fields: {', '.join(missing_fields)}"}, 400 - - host = request.host_url - - # now that we have a book object that is valid, loop through books - for book in books: - if book.get("id") == book_id: - # update the book values to what is in the request - book["title"] = request.json.get("title") - book["synopsis"] = request.json.get("synopsis") - book["author"] = request.json.get("author") - - # Add links exists as paths only - book["links"] = { - "self": f"/books/{book_id}", - "reservations": f"/books/{book_id}/reservations", - "reviews": f"/books/{book_id}/reviews" - } - # make a deepcopy of the modified book - book_copy = copy.deepcopy(book) - book_with_hostname = append_hostname(book_copy, host) - return jsonify(book_with_hostname), 200 - - return jsonify({"error": "Book not found"}), 404 - -@app.errorhandler(NotFound) -def handle_not_found(e): - """Return a custom JSON response for 404 Not Found errors.""" - return jsonify({"error": str(e)}), 404 - -@app.errorhandler(Exception) -def handle_exception(e): - """Return a custom JSON response for any exception.""" - return jsonify({"error": str(e)}), 500 diff --git a/app/__init__.py b/app/__init__.py index 3b15476..7e9ad60 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,3 +1,5 @@ +"""Initialize the Flask app and register all routes.""" + import os from dotenv import load_dotenv from flask import Flask @@ -11,7 +13,7 @@ app.config['COLLECTION_NAME'] = os.getenv('PROJECT_COLLECTION') # Import routes — routes can import app safely because it exists -from app.routes import register_routes +from app.routes import register_routes # pylint: disable=wrong-import-position register_routes(app) # Expose `app` for importing diff --git a/app/routes.py b/app/routes.py index 0d02f81..fe37adb 100644 --- a/app/routes.py +++ b/app/routes.py @@ -1,19 +1,25 @@ """Flask application module for managing a collection of books.""" import uuid import copy -# import os -# from dotenv import load_dotenv from flask import request, jsonify from werkzeug.exceptions import NotFound -from datastore.mongo_helper import insert_book_to_mongo -from utils.helper import append_hostname from datastore.mongo_db import get_book_collection +from datastore.mongo_helper import insert_book_to_mongo from data import books +from utils.helper import append_hostname + + # ----------- POST section ------------------ -def register_routes(app): +def register_routes(app): # pylint: disable=too-many-statements + """ + Register all Flask routes with the given app instance. + + Args: + app (Flask): The Flask application instance to register routes on. + """ @app.route("/books", methods=["POST"]) def add_book(): """Function to add a new book to the collection.""" @@ -88,7 +94,7 @@ def get_all_books(): for book in books: # check if the book has the "deleted" state if book.get("state")!="deleted": - # if the book has a state other than "deleted" remove the state field before appending + # 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) From e584c11f0010fbde361e922a828dced82bf84961 Mon Sep 17 00:00:00 2001 From: codesungrape Date: Fri, 20 Jun 2025 17:17:58 +0100 Subject: [PATCH 08/16] Move datastore/ and utils/ into app/ and update imports --- {datastore => app/datastore}/__init__.py | 0 {datastore => app/datastore}/mongo_db.py | 0 {datastore => app/datastore}/mongo_helper.py | 0 app/routes.py | 8 +++----- {utils => app/utils}/__init__.py | 0 {utils => app/utils}/helper.py | 0 run_tests.sh | 1 + tests/test_app.py | 7 +++++-- tests/test_mongo_helper.py | 4 +++- 9 files changed, 12 insertions(+), 8 deletions(-) rename {datastore => app/datastore}/__init__.py (100%) rename {datastore => app/datastore}/mongo_db.py (100%) rename {datastore => app/datastore}/mongo_helper.py (100%) rename {utils => app/utils}/__init__.py (100%) rename {utils => app/utils}/helper.py (100%) diff --git a/datastore/__init__.py b/app/datastore/__init__.py similarity index 100% rename from datastore/__init__.py rename to app/datastore/__init__.py diff --git a/datastore/mongo_db.py b/app/datastore/mongo_db.py similarity index 100% rename from datastore/mongo_db.py rename to app/datastore/mongo_db.py diff --git a/datastore/mongo_helper.py b/app/datastore/mongo_helper.py similarity index 100% rename from datastore/mongo_helper.py rename to app/datastore/mongo_helper.py diff --git a/app/routes.py b/app/routes.py index fe37adb..4c04a76 100644 --- a/app/routes.py +++ b/app/routes.py @@ -3,12 +3,10 @@ import copy from flask import request, jsonify from werkzeug.exceptions import NotFound -from datastore.mongo_db import get_book_collection -from datastore.mongo_helper import insert_book_to_mongo +from app.datastore.mongo_db import get_book_collection +from app.datastore.mongo_helper import insert_book_to_mongo +from app.utils.helper import append_hostname from data import books -from utils.helper import append_hostname - - diff --git a/utils/__init__.py b/app/utils/__init__.py similarity index 100% rename from utils/__init__.py rename to app/utils/__init__.py diff --git a/utils/helper.py b/app/utils/helper.py similarity index 100% rename from utils/helper.py rename to app/utils/helper.py diff --git a/run_tests.sh b/run_tests.sh index b2b2eb3..3da9636 100755 --- a/run_tests.sh +++ b/run_tests.sh @@ -1,4 +1,5 @@ #!/bin/bash + # Run pytest with coverage echo "Running tests with coverage..." coverage run -m pytest tests/test_app.py tests/test_mongo_helper.py tests/test_integration.py diff --git a/tests/test_app.py b/tests/test_app.py index 42412b3..93a234f 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,8 +1,11 @@ # pylint: disable=missing-docstring + +# Filepath is this: /Users/Shanti.Rai@methods.co.uk/Documents/S_BookAPIV.2/tests/test_app.py + from unittest.mock import patch from pymongo.errors import ServerSelectionTimeoutError import pytest -from datastore.mongo_db import get_book_collection +from app.datastore.mongo_db import get_book_collection from app import app @@ -554,7 +557,7 @@ def test_append_host_to_links_in_put(client): f"Link should end with the resource path '{expected_path}'" def test_get_book_collection_handles_connection_failure(): - with patch("datastore.mongo_db.MongoClient") as mock_client: + with patch("app.datastore.mongo_db.MongoClient") as mock_client: # Set the side effect to raise a ServerSelectionTimeoutError mock_client.side_effect = ServerSelectionTimeoutError("Mock Connection Timeout") diff --git a/tests/test_mongo_helper.py b/tests/test_mongo_helper.py index 9fa1706..d50d1a5 100644 --- a/tests/test_mongo_helper.py +++ b/tests/test_mongo_helper.py @@ -1,6 +1,8 @@ # pylint: disable=missing-docstring + +# Filepath: #/Users/Shanti.Rai@methods.co.uk/Documents/S_BookAPIV.2/app/datastore/mongo_helper.p from unittest.mock import MagicMock -from datastore.mongo_helper import insert_book_to_mongo +from app.datastore.mongo_helper import insert_book_to_mongo # @patch('mongo_helper.books_collection') def test_insert_book_to_mongo(): From 54bf43a2d2e6af8410c0061646d0d5b94c5468ac Mon Sep 17 00:00:00 2001 From: codesungrape Date: Mon, 23 Jun 2025 14:53:45 +0100 Subject: [PATCH 09/16] Add run.py as app entry point; refactor app/__init__.py to use create_app() factory --- app/__init__.py | 27 ++++++++++++++++----------- run.py | 11 +++++++++++ 2 files changed, 27 insertions(+), 11 deletions(-) create mode 100644 run.py diff --git a/app/__init__.py b/app/__init__.py index 7e9ad60..c5b2058 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -4,17 +4,22 @@ from dotenv import load_dotenv from flask import Flask -app = Flask(__name__) +def create_app(): + """Application factory pattern.""" + # Load the env variables to use here + load_dotenv() -# Use app.config to set config connection details -load_dotenv() -app.config['MONGO_URI'] = os.getenv('MONGO_CONNECTION') -app.config['DB_NAME'] = os.getenv('PROJECT_DATABASE') -app.config['COLLECTION_NAME'] = os.getenv('PROJECT_COLLECTION') + app = Flask(__name__) + # Use app.config to set config connection details + app.config['MONGO_URI'] = os.getenv('MONGO_CONNECTION') + app.config['DB_NAME'] = os.getenv('PROJECT_DATABASE') + app.config['COLLECTION_NAME'] = os.getenv('PROJECT_COLLECTION') -# Import routes — routes can import app safely because it exists -from app.routes import register_routes # pylint: disable=wrong-import-position -register_routes(app) + # Import routes — routes can import app safely because it exists + from app.routes import register_routes # pylint: disable=wrong-import-position + register_routes(app) -# Expose `app` for importing -__all__ = ["app"] + return app + +# # Expose `app` for importing +# __all__ = ["app"] diff --git a/run.py b/run.py new file mode 100644 index 0000000..1366a7e --- /dev/null +++ b/run.py @@ -0,0 +1,11 @@ +"""Entry point for running the Flask application. + +This script imports the Flask application factory from the app package, +creates an instance of the application, and runs it in debug mode. +""" +from app import create_app + +app = create_app() + +if __name__ == "__main__": + app.run(debug=True) From b55ad4aad0feeb1edfd636670a8f578c554c0806 Mon Sep 17 00:00:00 2001 From: codesungrape Date: Mon, 23 Jun 2025 14:55:24 +0100 Subject: [PATCH 10/16] Refactor test setup: update imports and initialize app using create_app() fixture --- tests/test_app.py | 4 +++- tests/test_integration.py | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/test_app.py b/tests/test_app.py index 93a234f..de12ebd 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -6,13 +6,14 @@ from pymongo.errors import ServerSelectionTimeoutError import pytest from app.datastore.mongo_db import get_book_collection -from app import app +from app import create_app # Option 1: Rename the fixture to something unique (which I've used) # Option 2: Use a linter plugin that understands pytest @pytest.fixture(name="client") def client_fixture(): + app = create_app() app.config['TESTING'] = True return app.test_client() @@ -561,6 +562,7 @@ def test_get_book_collection_handles_connection_failure(): # Set the side effect to raise a ServerSelectionTimeoutError mock_client.side_effect = ServerSelectionTimeoutError("Mock Connection Timeout") + app = create_app() with app.app_context(): # <-- Push the app context here with pytest.raises(Exception) as exc_info: get_book_collection() diff --git a/tests/test_integration.py b/tests/test_integration.py index 44056d0..15f45d0 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -1,12 +1,13 @@ # pylint: disable=missing-docstring import pytest from pymongo import MongoClient -from app import app +from app import create_app @pytest.fixture(name="client") def client_fixture(): """Provides a test client for making requests to our Flask app.""" + app = create_app() app.config['TESTING'] = True app.config['MONGO_URI'] = 'mongodb://localhost:27017/' app.config['DB_NAME'] = 'test_database' From 16b3c915f100c1b89a297b7180434a0efff30838 Mon Sep 17 00:00:00 2001 From: codesungrape Date: Mon, 23 Jun 2025 15:03:51 +0100 Subject: [PATCH 11/16] Fix pylint import warning by replacing wrong-import-position with import-outside-toplevel --- app/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/__init__.py b/app/__init__.py index c5b2058..c6cdbff 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -16,7 +16,7 @@ def create_app(): app.config['COLLECTION_NAME'] = os.getenv('PROJECT_COLLECTION') # Import routes — routes can import app safely because it exists - from app.routes import register_routes # pylint: disable=wrong-import-position + from app.routes import register_routes # pylint: disable=import-outside-toplevel register_routes(app) return app From b531ed8be5a0c8c479c216519ef42ff5fbbbe735 Mon Sep 17 00:00:00 2001 From: codesungrape Date: Mon, 23 Jun 2025 15:06:31 +0100 Subject: [PATCH 12/16] Remove unused global app exposure in __init__.py; rely on create_app factory pattern --- app/__init__.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/__init__.py b/app/__init__.py index c6cdbff..8955201 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -20,6 +20,3 @@ def create_app(): register_routes(app) return app - -# # Expose `app` for importing -# __all__ = ["app"] From 543c28b50a5d92f200afaa2fc2a38174ccb19ee2 Mon Sep 17 00:00:00 2001 From: codesungrape Date: Mon, 23 Jun 2025 15:22:05 +0100 Subject: [PATCH 13/16] Remove app.run() block; rely on Flask CLI with create_app factory pattern --- run.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/run.py b/run.py index 1366a7e..089d99f 100644 --- a/run.py +++ b/run.py @@ -7,5 +7,5 @@ app = create_app() -if __name__ == "__main__": - app.run(debug=True) +# if __name__ == "__main__": +# app.run(debug=True) From e46aba2994a1426836633fba2f5f04b7e11c04dc Mon Sep 17 00:00:00 2001 From: codesungrape Date: Mon, 23 Jun 2025 15:22:37 +0100 Subject: [PATCH 14/16] Remove app.run() block; rely on Flask CLI with create_app factory pattern --- run.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/run.py b/run.py index 089d99f..62858c1 100644 --- a/run.py +++ b/run.py @@ -6,6 +6,3 @@ from app import create_app app = create_app() - -# if __name__ == "__main__": -# app.run(debug=True) From 3d26b71ab8d70e222dd2d0b7eaae730aba035392 Mon Sep 17 00:00:00 2001 From: codesungrape Date: Mon, 23 Jun 2025 15:43:08 +0100 Subject: [PATCH 15/16] Fix spelling errors and remove unnecessary filepath comments --- app/routes.py | 2 +- app/utils/helper.py | 2 +- tests/test_app.py | 2 -- tests/test_mongo_helper.py | 1 - 4 files changed, 2 insertions(+), 5 deletions(-) diff --git a/app/routes.py b/app/routes.py index 4c04a76..34650d3 100644 --- a/app/routes.py +++ b/app/routes.py @@ -68,7 +68,7 @@ def add_book(): # Send the host and new book_id to the helper function to generate links book_for_response = append_hostname(new_book, host) print("book_for_response", book_for_response) - # Remove MOngoDB's ObjectID value + # Remove MongoDB's ObjectID value book_for_response.pop('_id', None) return jsonify(book_for_response), 201 diff --git a/app/utils/helper.py b/app/utils/helper.py index a08d6c2..f56b2b6 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -1,4 +1,4 @@ -""" Collection of helper fucntions """ +""" Collection of helper functions """ from urllib.parse import urljoin diff --git a/tests/test_app.py b/tests/test_app.py index de12ebd..b9e3c01 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,7 +1,5 @@ # pylint: disable=missing-docstring -# Filepath is this: /Users/Shanti.Rai@methods.co.uk/Documents/S_BookAPIV.2/tests/test_app.py - from unittest.mock import patch from pymongo.errors import ServerSelectionTimeoutError import pytest diff --git a/tests/test_mongo_helper.py b/tests/test_mongo_helper.py index d50d1a5..9f073a2 100644 --- a/tests/test_mongo_helper.py +++ b/tests/test_mongo_helper.py @@ -1,6 +1,5 @@ # pylint: disable=missing-docstring -# Filepath: #/Users/Shanti.Rai@methods.co.uk/Documents/S_BookAPIV.2/app/datastore/mongo_helper.p from unittest.mock import MagicMock from app.datastore.mongo_helper import insert_book_to_mongo From f46c5dd952d8aaa7fe0ec7b0c01957d4b06150e0 Mon Sep 17 00:00:00 2001 From: codesungrape Date: Mon, 23 Jun 2025 16:39:32 +0100 Subject: [PATCH 16/16] Resolve pylint trailingnewline error --- app/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/app/__init__.py b/app/__init__.py index 39a894d..8955201 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -20,4 +20,3 @@ def create_app(): register_routes(app) return app -