From a6db6703f006be5ae4ea1848d2520aee21b36cd3 Mon Sep 17 00:00:00 2001 From: Eric Date: Wed, 10 Apr 2024 19:41:43 -0400 Subject: [PATCH 01/17] refactored to RESTX --- service/__init__.py | 16 ++ service/common/error_handlers.py | 134 +++++----- service/models.py | 6 + service/routes.py | 435 +++++++++++++++++++------------ service/static/index.html | 3 + tests/test_routes.py | 2 +- 6 files changed, 359 insertions(+), 237 deletions(-) diff --git a/service/__init__.py b/service/__init__.py index e7464c8..a5c406b 100644 --- a/service/__init__.py +++ b/service/__init__.py @@ -20,6 +20,7 @@ """ import sys from flask import Flask +from flask_restx import Api from service import config from service.common import log_handlers @@ -33,17 +34,32 @@ def create_app(): app = Flask(__name__) app.config.from_object(config) + app.url_map.strict_slashes = False + # Initialize Plugins # pylint: disable=import-outside-toplevel from service.models import db db.init_app(app) + # Configure Swagger before initializing it + api = Api( + app, + version="1.0.0", + title="Inventory REST API Service", + description="This is an inventory server.", + default="Inventory", + default_label="inventory operations", + doc="/apidocs", # default also could use doc='/apidocs/' + prefix="/api", + ) + with app.app_context(): # Dependencies require we import the routes AFTER the Flask app is created # pylint: disable=wrong-import-position, wrong-import-order, unused-import from service import routes, models # noqa: F401 E402 from service.common import error_handlers, cli_commands # noqa: F401, E402 + # try creating all tables of db try: db.create_all() except Exception as error: # pylint: disable=broad-except diff --git a/service/common/error_handlers.py b/service/common/error_handlers.py index 3717a3b..315be54 100644 --- a/service/common/error_handlers.py +++ b/service/common/error_handlers.py @@ -16,85 +16,89 @@ """ Module: error_handlers """ -from flask import jsonify -from flask import current_app as app # Import Flask application -from service.models import DataValidationError +from service import app, api +# from flask import jsonify +# from flask import current_app as app # Import Flask application +from service.models import DataValidationError, DatabaseConnectionError from . import status ###################################################################### # Error Handlers ###################################################################### -@app.errorhandler(DataValidationError) +@api.errorhandler(DataValidationError) def request_validation_error(error): """Handles Value Errors from bad data""" - return bad_request(error) - - -@app.errorhandler(status.HTTP_400_BAD_REQUEST) -def bad_request(error): - """Handles bad requests with 400_BAD_REQUEST""" message = str(error) - app.logger.warning(message) - return ( - jsonify( - status=status.HTTP_400_BAD_REQUEST, error="Bad Request", message=message - ), - status.HTTP_400_BAD_REQUEST, - ) - + app.logger.error(message) + return { + "status_code": status.HTTP_400_BAD_REQUEST, + "error": "Bad Request", + "message": message, + }, status.HTTP_400_BAD_REQUEST -@app.errorhandler(status.HTTP_404_NOT_FOUND) -def not_found(error): - """Handles resources not found with 404_NOT_FOUND""" +@api.errorhandler(DatabaseConnectionError) +def database_connection_error(error): + """Handles Database Errors from connection attempts""" message = str(error) - app.logger.warning(message) - return ( - jsonify(status=status.HTTP_404_NOT_FOUND, error="Not Found", message=message), - status.HTTP_404_NOT_FOUND, - ) + app.logger.critical(message) + return { + "status_code": status.HTTP_503_SERVICE_UNAVAILABLE, + "error": "Service Unavailable", + "message": message, + }, status.HTTP_503_SERVICE_UNAVAILABLE +# @app.errorhandler(status.HTTP_404_NOT_FOUND) +# def not_found(error): +# """Handles resources not found with 404_NOT_FOUND""" +# message = str(error) +# app.logger.warning(message) +# return ( +# jsonify(status=status.HTTP_404_NOT_FOUND, error="Not Found", message=message), +# status.HTTP_404_NOT_FOUND, +# ) -@app.errorhandler(status.HTTP_405_METHOD_NOT_ALLOWED) -def method_not_supported(error): - """Handles unsupported HTTP methods with 405_METHOD_NOT_SUPPORTED""" - message = str(error) - app.logger.warning(message) - return ( - jsonify( - status=status.HTTP_405_METHOD_NOT_ALLOWED, - error="Method not Allowed", - message=message, - ), - status.HTTP_405_METHOD_NOT_ALLOWED, - ) +# @app.errorhandler(status.HTTP_405_METHOD_NOT_ALLOWED) +# def method_not_supported(error): +# """Handles unsupported HTTP methods with 405_METHOD_NOT_SUPPORTED""" +# message = str(error) +# app.logger.warning(message) +# return ( +# jsonify( +# status=status.HTTP_405_METHOD_NOT_ALLOWED, +# error="Method not Allowed", +# message=message, +# ), +# status.HTTP_405_METHOD_NOT_ALLOWED, +# ) -@app.errorhandler(status.HTTP_415_UNSUPPORTED_MEDIA_TYPE) -def mediatype_not_supported(error): - """Handles unsupported media requests with 415_UNSUPPORTED_MEDIA_TYPE""" - message = str(error) - app.logger.warning(message) - return ( - jsonify( - status=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, - error="Unsupported media type", - message=message, - ), - status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, - ) +# @app.errorhandler(status.HTTP_415_UNSUPPORTED_MEDIA_TYPE) +# def mediatype_not_supported(error): +# """Handles unsupported media requests with 415_UNSUPPORTED_MEDIA_TYPE""" +# message = str(error) +# app.logger.warning(message) +# return ( +# jsonify( +# status=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, +# error="Unsupported media type", +# message=message, +# ), +# status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, +# ) -@app.errorhandler(status.HTTP_500_INTERNAL_SERVER_ERROR) -def internal_server_error(error): - """Handles unexpected server error with 500_SERVER_ERROR""" - message = str(error) - app.logger.error(message) - return ( - jsonify( - status=status.HTTP_500_INTERNAL_SERVER_ERROR, - error="Internal Server Error", - message=message, - ), - status.HTTP_500_INTERNAL_SERVER_ERROR, - ) + +# @app.errorhandler(status.HTTP_500_INTERNAL_SERVER_ERROR) +# def internal_server_error(error): +# """Handles unexpected server error with 500_SERVER_ERROR""" +# message = str(error) +# app.logger.error(message) +# return ( +# jsonify( +# status=status.HTTP_500_INTERNAL_SERVER_ERROR, +# error="Internal Server Error", +# message=message, +# ), +# status.HTTP_500_INTERNAL_SERVER_ERROR, +# ) diff --git a/service/models.py b/service/models.py index bb3b9cb..f2a9253 100644 --- a/service/models.py +++ b/service/models.py @@ -184,3 +184,9 @@ def find_by_restock_level(cls, restock_level: int) -> list: """Returns all of the Inventories in a restock_level""" logger.info("Processing quantity query for %s ...", restock_level) return cls.query.filter(cls.restock_level == restock_level) + + @classmethod + def remove_all(cls): + """Removes all documents from the database (use for testing)""" + for document in cls.database: # pylint: disable=(not-an-iterable + document.delete() diff --git a/service/routes.py b/service/routes.py index f2032fd..7d06d27 100644 --- a/service/routes.py +++ b/service/routes.py @@ -21,10 +21,12 @@ and Delete Items from the inventory of Items in the InventoryShop """ -from flask import jsonify, request, url_for, abort +from flask import jsonify, abort from flask import current_app as app # Import Flask application -from service.models import Inventory +from flask_restx import Resource, fields, reqparse +from service.models import Inventory, Condition from service.common import status # HTTP Status Codes +from . import app, api ###################################################################### @@ -45,197 +47,265 @@ def index(): return app.send_static_file("index.html") -###################################################################### -# R E S T A P I E N D P O I N T S -###################################################################### - - -###################################################################### -# LIST ALL ITEMS -###################################################################### -@app.route("/inventory", methods=["GET"]) -def list_inventory(): - """Returns all of the Items""" - app.logger.info("Request for item list") - - inventory = [] - - # See if any query filters were passed in - category = request.args.get("category") - name = request.args.get("name") - condition = request.args.get("condition") - restock_level = request.args.get("restock_level") - quantity = request.args.get("quantity") - if restock_level: - restock_level_num = int(restock_level) - if quantity: - quantity_num = int(quantity) - if name: - inventory = Inventory.find_by_inventory_name(name) - elif category: - inventory = Inventory.find_by_category(category) - elif quantity: - inventory = Inventory.find_by_quantity(quantity_num) - elif condition: - inventory = Inventory.find_by_condition(condition) - elif restock_level: - inventory = Inventory.find_by_restock_level(restock_level_num) - else: - inventory = Inventory.all() - - results = [item.serialize() for item in inventory] - app.logger.info("Returning %d inventory", len(results)) - return jsonify(results), status.HTTP_200_OK +# Define the model so that the docs reflect what can be sent +inventory_item = api.model( + "InventoryItem", + { + "inventory_name": fields.String(required=True, description="The name of an item"), + "category": fields.String( + required=True, + description="The category of an item", + ), + "quantity": fields.Integer( + required=True, description="The quantity of an item" + ), + "condition": fields.String( + enum=Condition._member_names_, + description="The condition of an item (NEW, OPENED, USED)", + ), + "restock_level": fields.Integer( + required=True, description="The restock level of an item" + ), + }, +) + +item_model = api.inherit( + "ItemModel", + inventory_item, + { + "_id": fields.String( + readOnly=True, description="The unique id assigned internally by service" + ), + }, +) + +# Tell RESTX how to handle query string arguments +item_args = reqparse.RequestParser() +item_args.add_argument( + "quantity", type=int, location="args", required=False, + help="List items by quantity" +) +item_args.add_argument( + "condition", type=str, location="args", required=False, + help="List items by condition" +) ###################################################################### -# CREATE A NEW INVENTORY +# R E S T A P I E N D P O I N T S ###################################################################### -@app.route("/inventory", methods=["POST"]) -def create_inventory(): - """ - Creates an Inventory - - This endpoint will create a Inventory based the data in the body that is posted - """ - app.logger.info("Request to create an inventory") - check_content_type("application/json") - - inventory = Inventory() - inventory.deserialize(request.get_json()) - inventory.create() - message = inventory.serialize() - location_url = url_for("get_inventory", id=inventory.id, _external=True) - - app.logger.info("Inventory with ID: %d created.", inventory.id) - return jsonify(message), status.HTTP_201_CREATED, {"Location": location_url} ###################################################################### -# READ AN INVENTORY +# PATH: /inventory/{id} ###################################################################### -# pylint: disable=redefined-builtin -@app.route("/inventory/", methods=["GET"]) -def get_inventory(id): - """ - Retrieve a single Inventory +@api.route("/inventory/") +@api.param("id", "The inventory identifier") +class InventoryResource(Resource): - This endpoint will return a Inventory based on it's id """ - app.logger.info("Request for inventory with id: %s", id) - - inventory = Inventory.find(id) - if not inventory: - error(status.HTTP_404_NOT_FOUND, f"Inventory with id '{id}' was not found.") + InventoryResource class - app.logger.info("Returning inventory: %s", inventory.inventory_name) - return jsonify(inventory.serialize()), status.HTTP_200_OK - - -###################################################################### -# DELETE A INVENTORY -###################################################################### -# pylint: disable=redefined-builtin -@app.route("/inventory/", methods=["DELETE"]) -def delete_inventory(id): - """ - Delete an Inventory. - This endpoint will delete an Inventory based on the id. + Allows the manipulation of a single inventory item + GET /inventory{id} - Returns an item with the id + PUT /inventory{id} - Update an item with the id + DELETE /inventory{id} - Deletes an item with the id """ - app.logger.info(f"Request to delete inventory with key ({format(id)})") - - # Find the inventory by id - inventory = Inventory.find(id) - if inventory is None: + # ------------------------------------------------------------------ + # RETRIEVE AN ITEM + # ------------------------------------------------------------------ + @api.doc("get_item") + @api.response(404, "Item not found") + @api.marshal_with(item_model) + def get(self, id): + """ + Retrieve a single item + + This endpoint will return an item based on it's id + """ + app.logger.info("Request for item with id: %s", id) + item = Inventory.find(id) + if not item: + error(status.HTTP_404_NOT_FOUND, + f"Item with id '{id}' was not found.") + app.logger.info("Returning item: %s", item.inventory_name) + return item.serialize(), status.HTTP_200_OK + + # ------------------------------------------------------------------ + # UPDATE AN EXISTING PET + # ------------------------------------------------------------------ + @api.doc("update_item") + @api.response(404, "Item not found") + @api.response(400, "The posted Item data was not valid") + @api.expect(item_model) + @api.marshal_with(item_model) + def put(self, id): + """ + Update an item + + This endpoint will update an item based the body that is posted + """ + app.logger.info("Request to update item with id [%s]", id) + item = Inventory.find(id) + if not item: + error(status.HTTP_404_NOT_FOUND, + f"Item with id: '{id}' was not found.") + data = api.payload + app.logger.debug("Payload = %s", data) + item.deserialize(data) + item.id = id + item.update() + app.logger.info("Item %s updated.", item.inventory_name) + return item.serialize(), status.HTTP_200_OK + + # ------------------------------------------------------------------ + # DELETE AN ITEM + # ------------------------------------------------------------------ + @api.doc("delete_item") + @api.response(204, "Item deleted") + def delete(self, id): + """ + Delete an item. + This endpoint will delete an item based on the id. + """ + app.logger.info("Request to Delete an item with id [%s]", id) + item = Inventory.find(id) + if item: + item.delete() + app.logger.info("Item %s deleted", item.inventory_name) return "", status.HTTP_204_NO_CONTENT - # Delete the inventory - inventory.delete() - - app.logger.info(f"Inventory with id {format(id)} deleted") - - # Return a response with 204 No Content status code - return "", status.HTTP_204_NO_CONTENT - ###################################################################### -# UPDATE AN EXISTING INVENTORY +# PATH: /inventory ###################################################################### -# pylint: disable=redefined-builtin -@app.route("/inventory/", methods=["PUT"]) -def update_inventories(id): - """ - Update a Inventory - - This endpoint will update a Inventory based the body that is posted - """ - app.logger.info("Request to update inventory with id: %d", id) - check_content_type("application/json") - - inventory = Inventory.find(id) - if not inventory: - error(status.HTTP_404_NOT_FOUND, f"Inventory with id: '{id}' was not found.") - - inventory.deserialize(request.get_json()) - inventory.id = id - inventory.update() - - app.logger.info("Inventory with ID: %d updated.", inventory.id) - return jsonify(inventory.serialize()), status.HTTP_200_OK - - -###################################################################### -# RESTOCK -###################################################################### -@app.route("/inventory//restock", methods=["PUT"]) -def restock(id): - """ - Restock - - This endpoint will restock and change the quantity - """ - app.logger.info("Request to request with id: %d", id) - - quantity = request.args.get("quantity") - if not quantity: - quantity = 1 - inventory = Inventory.find(id) - if not inventory: - error(status.HTTP_404_NOT_FOUND, f"Inventory with id '{id}' was not found.") - resultquantity = inventory.quantity + int(quantity) - if resultquantity > inventory.restock_level: - error( - status.HTTP_409_CONFLICT, - f"Inventory with ID: '{id}' quantity exceeds restock_level.", - ) - inventory.quantity = resultquantity - inventory.update() - - app.logger.info("Inventory with ID: %d has been restocked.", id) - return inventory.serialize(), status.HTTP_200_OK +@api.route("/inventory", strict_slashes=False) +class InventoryCollection(Resource): + """Handles all interactions with collections of Inventory""" + + # ------------------------------------------------------------------ + # LIST ALL ITEMS IN THE INVENTORY + # ------------------------------------------------------------------ + @api.doc("list_items") + @api.expect(item_args, validate=True) + @api.marshal_list_with(item_model) + def get(self): + """Returns all of the Items""" + app.logger.info("Request for item list") + inventory = [] + args = item_args.parse_args() + + name = args["name"] + category = args["category"] + quantity = args["quantity"] + condition = args["condition"] + restock_level = args["restock_level"] + + if name: + app.logger.info("Filtering by name: %s", name) + inventory = Inventory.find_by_inventory_name(name) + elif category: + app.logger.info("Filtering by category: %s", category) + inventory = Inventory.find_by_category(category) + elif quantity: + app.logger.info("Filtering by quantity: %s", int(quantity)) + inventory = Inventory.find_by_quantity(int(quantity)) + elif condition: + app.logger.info("Filtering by condition: %s", condition) + inventory = Inventory.find_by_condition(condition) + elif restock_level: + app.logger.info("Filtering by restock_level: %s", + int(restock_level)) + inventory = Inventory.find_by_restock_level(int(restock_level)) + else: + app.logger.info("Returning unfiltered list.") + inventory = Inventory.all() + + app.logger.info("Returning %d items", len(inventory)) + results = [item.serialize() for item in inventory] + return results, status.HTTP_200_OK + + # ------------------------------------------------------------------ + # ADD A NEW ITEM + # ------------------------------------------------------------------ + @api.doc("create_item") + @api.response(400, "The posted data was not valid") + @api.expect(inventory_item) + @api.marshal_with(item_model, code=201) + def post(self): + """ + Creates an item + + This endpoint will create an item based the data in the body that is posted + """ + app.logger.info("Request to create an item") + item = Inventory() + app.logger.debug("Payload = %s", api.payload) + item.deserialize(api.payload) + item.create() + app.logger.info("Item %s created.", item.inventory_name) + location_url = api.url_for(InventoryResource, + id=item.id, _external=True) + return item.serialize(), status.HTTP_201_CREATED, {"Location": location_url} + + + # ------------------------------------------------------------------ + # DELETE ALL PETS (for testing only) + # ------------------------------------------------------------------ + @api.doc("delete_all_items", security="apikey") + @api.response(204, "All Items deleted") + def delete(self): + """ + Delete all Items + + This endpoint will delete all Items only if the system is under test + """ + app.logger.info("Request to Delete all items...") + if "TESTING" in app.config and app.config["TESTING"]: + Inventory.remove_all() + app.logger.info("Removed all Items from the database") + else: + app.logger.warning("Request to clear database while system not under test") + return "", status.HTTP_204_NO_CONTENT ###################################################################### -# Checks the ContentType of a request +# PATH: /inventory/{id}/restock ###################################################################### -def check_content_type(content_type): - """Checks that the media type is correct""" - if "Content-Type" not in request.headers: - app.logger.error("No Content-Type specified.") - error( - status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, - f"Content-Type must be {content_type}", - ) - - if request.headers["Content-Type"] == content_type: - return - - app.logger.error("Invalid Content-Type: %s", request.headers["Content-Type"]) - error( - status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, - f"Content-Type must be {content_type}", - ) +@api.route("/inventory//restock") +@api.param("id", "The Inventory identifier") +class RestockResource(Resource): + """Restock actions on an item""" + @api.doc("restock_item") + @api.response(404, "Item not found") + @api.response(400, "Bad quantity: quantity not enough for restock") + def put(self, id): + """ + Restock an item + + This endpoint will restock an item and change the quantity + """ + app.logger.info("Request to restock with id: %d", id) + item = Inventory.find(id) + if not item: + error(status.HTTP_404_NOT_FOUND, + f"Item with id '{id}' was not found.") + + entered_quantity = int(api.payload["quantity"]) + # args = item_args.parse_args() + # entered_quantity = args["quantity"] + new_quantity = item.quantity + int(entered_quantity) + if new_quantity < item.restock_level: + error( + status.HTTP_400_BAD_REQUEST, + f"New quantity [{new_quantity}] still below restock level [{item.restock_level}]", + ) + + item.quantity = new_quantity + item.update() + app.logger.info("Item %s restocked.", item.inventory_name) + return item.serialize(), status.HTTP_200_OK ###################################################################### @@ -245,3 +315,26 @@ def error(status_code, reason): """Logs the error and then aborts""" app.logger.error(reason) abort(status_code, reason) + + +# Below code no longer needed because reqparse does checks for us +# ###################################################################### +# # Checks the ContentType of a request +# ###################################################################### +# def check_content_type(content_type): +# """Checks that the media type is correct""" +# if "Content-Type" not in request.headers: +# app.logger.error("No Content-Type specified.") +# error( +# status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, +# f"Content-Type must be {content_type}", +# ) + +# if request.headers["Content-Type"] == content_type: +# return + +# app.logger.error("Invalid Content-Type: %s", request.headers["Content-Type"]) +# error( +# status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, +# f"Content-Type must be {content_type}", +# ) diff --git a/service/static/index.html b/service/static/index.html index bbd2f80..1755c66 100644 --- a/service/static/index.html +++ b/service/static/index.html @@ -136,5 +136,8 @@

Create, Retrieve, Update, and Delete an Inventory:

+ +

API Documentation is here

+ diff --git a/tests/test_routes.py b/tests/test_routes.py index c681679..a561b73 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -14,7 +14,7 @@ "DATABASE_URI", "postgresql+psycopg://postgres:postgres@localhost:5432/testdb" ) -BASE_URL = "/inventory" +BASE_URL = "/api/inventory" ###################################################################### From 1849c15b57b7e62ec82d3cc6600ce2de813b2745 Mon Sep 17 00:00:00 2001 From: Eric Date: Wed, 10 Apr 2024 21:43:01 -0400 Subject: [PATCH 02/17] adapted wsgi.py and create_app --- service/__init__.py | 5 +-- service/common/error_handlers.py | 5 ++- service/models.py | 2 +- service/routes.py | 54 ++++++++++++++++---------------- wsgi.py | 5 +-- 5 files changed, 38 insertions(+), 33 deletions(-) diff --git a/service/__init__.py b/service/__init__.py index a5c406b..0a6999c 100644 --- a/service/__init__.py +++ b/service/__init__.py @@ -28,7 +28,7 @@ ############################################################ # Initialize the Flask instance ############################################################ -def create_app(): +def create_api(): """Initialize the core application.""" # Create Flask application app = Flask(__name__) @@ -39,6 +39,7 @@ def create_app(): # Initialize Plugins # pylint: disable=import-outside-toplevel from service.models import db + db.init_app(app) # Configure Swagger before initializing it @@ -76,4 +77,4 @@ def create_app(): app.logger.info("Service initialized!") - return app + return api diff --git a/service/common/error_handlers.py b/service/common/error_handlers.py index 315be54..b2e656f 100644 --- a/service/common/error_handlers.py +++ b/service/common/error_handlers.py @@ -16,7 +16,8 @@ """ Module: error_handlers """ -from service import app, api +from wsgi import app, api + # from flask import jsonify # from flask import current_app as app # Import Flask application from service.models import DataValidationError, DatabaseConnectionError @@ -37,6 +38,7 @@ def request_validation_error(error): "message": message, }, status.HTTP_400_BAD_REQUEST + @api.errorhandler(DatabaseConnectionError) def database_connection_error(error): """Handles Database Errors from connection attempts""" @@ -48,6 +50,7 @@ def database_connection_error(error): "message": message, }, status.HTTP_503_SERVICE_UNAVAILABLE + # @app.errorhandler(status.HTTP_404_NOT_FOUND) # def not_found(error): # """Handles resources not found with 404_NOT_FOUND""" diff --git a/service/models.py b/service/models.py index f2a9253..382c690 100644 --- a/service/models.py +++ b/service/models.py @@ -184,7 +184,7 @@ def find_by_restock_level(cls, restock_level: int) -> list: """Returns all of the Inventories in a restock_level""" logger.info("Processing quantity query for %s ...", restock_level) return cls.query.filter(cls.restock_level == restock_level) - + @classmethod def remove_all(cls): """Removes all documents from the database (use for testing)""" diff --git a/service/routes.py b/service/routes.py index 7d06d27..086387d 100644 --- a/service/routes.py +++ b/service/routes.py @@ -22,11 +22,12 @@ """ from flask import jsonify, abort -from flask import current_app as app # Import Flask application + +# from flask import current_app as app # Import Flask application from flask_restx import Resource, fields, reqparse from service.models import Inventory, Condition from service.common import status # HTTP Status Codes -from . import app, api +from wsgi import app, api ###################################################################### @@ -51,7 +52,9 @@ def index(): inventory_item = api.model( "InventoryItem", { - "inventory_name": fields.String(required=True, description="The name of an item"), + "inventory_name": fields.String( + required=True, description="The name of an item" + ), "category": fields.String( required=True, description="The category of an item", @@ -82,12 +85,14 @@ def index(): # Tell RESTX how to handle query string arguments item_args = reqparse.RequestParser() item_args.add_argument( - "quantity", type=int, location="args", required=False, - help="List items by quantity" + "quantity", type=int, location="args", required=False, help="List items by quantity" ) item_args.add_argument( - "condition", type=str, location="args", required=False, - help="List items by condition" + "condition", + type=str, + location="args", + required=False, + help="List items by condition", ) @@ -102,7 +107,6 @@ def index(): @api.route("/inventory/") @api.param("id", "The inventory identifier") class InventoryResource(Resource): - """ InventoryResource class @@ -127,11 +131,10 @@ def get(self, id): app.logger.info("Request for item with id: %s", id) item = Inventory.find(id) if not item: - error(status.HTTP_404_NOT_FOUND, - f"Item with id '{id}' was not found.") + error(status.HTTP_404_NOT_FOUND, f"Item with id '{id}' was not found.") app.logger.info("Returning item: %s", item.inventory_name) return item.serialize(), status.HTTP_200_OK - + # ------------------------------------------------------------------ # UPDATE AN EXISTING PET # ------------------------------------------------------------------ @@ -149,8 +152,7 @@ def put(self, id): app.logger.info("Request to update item with id [%s]", id) item = Inventory.find(id) if not item: - error(status.HTTP_404_NOT_FOUND, - f"Item with id: '{id}' was not found.") + error(status.HTTP_404_NOT_FOUND, f"Item with id: '{id}' was not found.") data = api.payload app.logger.debug("Payload = %s", data) item.deserialize(data) @@ -158,7 +160,7 @@ def put(self, id): item.update() app.logger.info("Item %s updated.", item.inventory_name) return item.serialize(), status.HTTP_200_OK - + # ------------------------------------------------------------------ # DELETE AN ITEM # ------------------------------------------------------------------ @@ -201,7 +203,7 @@ def get(self): quantity = args["quantity"] condition = args["condition"] restock_level = args["restock_level"] - + if name: app.logger.info("Filtering by name: %s", name) inventory = Inventory.find_by_inventory_name(name) @@ -215,17 +217,16 @@ def get(self): app.logger.info("Filtering by condition: %s", condition) inventory = Inventory.find_by_condition(condition) elif restock_level: - app.logger.info("Filtering by restock_level: %s", - int(restock_level)) + app.logger.info("Filtering by restock_level: %s", int(restock_level)) inventory = Inventory.find_by_restock_level(int(restock_level)) else: app.logger.info("Returning unfiltered list.") inventory = Inventory.all() - + app.logger.info("Returning %d items", len(inventory)) results = [item.serialize() for item in inventory] return results, status.HTTP_200_OK - + # ------------------------------------------------------------------ # ADD A NEW ITEM # ------------------------------------------------------------------ @@ -245,11 +246,9 @@ def post(self): item.deserialize(api.payload) item.create() app.logger.info("Item %s created.", item.inventory_name) - location_url = api.url_for(InventoryResource, - id=item.id, _external=True) + location_url = api.url_for(InventoryResource, id=item.id, _external=True) return item.serialize(), status.HTTP_201_CREATED, {"Location": location_url} - # ------------------------------------------------------------------ # DELETE ALL PETS (for testing only) # ------------------------------------------------------------------ @@ -270,6 +269,7 @@ def delete(self): return "", status.HTTP_204_NO_CONTENT + ###################################################################### # PATH: /inventory/{id}/restock ###################################################################### @@ -277,6 +277,7 @@ def delete(self): @api.param("id", "The Inventory identifier") class RestockResource(Resource): """Restock actions on an item""" + @api.doc("restock_item") @api.response(404, "Item not found") @api.response(400, "Bad quantity: quantity not enough for restock") @@ -289,9 +290,8 @@ def put(self, id): app.logger.info("Request to restock with id: %d", id) item = Inventory.find(id) if not item: - error(status.HTTP_404_NOT_FOUND, - f"Item with id '{id}' was not found.") - + error(status.HTTP_404_NOT_FOUND, f"Item with id '{id}' was not found.") + entered_quantity = int(api.payload["quantity"]) # args = item_args.parse_args() # entered_quantity = args["quantity"] @@ -301,7 +301,7 @@ def put(self, id): status.HTTP_400_BAD_REQUEST, f"New quantity [{new_quantity}] still below restock level [{item.restock_level}]", ) - + item.quantity = new_quantity item.update() app.logger.info("Item %s restocked.", item.inventory_name) @@ -317,7 +317,7 @@ def error(status_code, reason): abort(status_code, reason) -# Below code no longer needed because reqparse does checks for us +# Below code no longer needed because reqparse does checks for us # ###################################################################### # # Checks the ContentType of a request # ###################################################################### diff --git a/wsgi.py b/wsgi.py index 7e780ad..5c8fff1 100644 --- a/wsgi.py +++ b/wsgi.py @@ -2,11 +2,12 @@ Web Server Gateway Interface (WSGI) entry point """ import os -from service import create_app +from service import create_api PORT = int(os.getenv("PORT", "8000")) -app = create_app() +api = create_api() +app = api.app if __name__ == "__main__": app.run(host='0.0.0.0', port=PORT) From ba0673e343a2447db0d0db485956c8bb15f7614a Mon Sep 17 00:00:00 2001 From: Eric Date: Thu, 11 Apr 2024 00:59:33 -0400 Subject: [PATCH 03/17] fixed "no module named 'flask_restx'" --- pyproject.toml | 1 + service/__init__.py | 5 +++-- service/common/error_handlers.py | 5 ++--- service/models.py | 2 ++ service/routes.py | 5 ++--- service/static/index.html | 6 +++--- wsgi.py | 5 ++--- 7 files changed, 15 insertions(+), 14 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index afca226..1a33726 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ readme = "README.md" [tool.poetry.dependencies] python = "^3.11" Flask = "^3.0.2" +flask-restx = "^1.3.0" Flask-SQLAlchemy = "^3.1.1" psycopg = {extras = ["binary"], version = "^3.1.17"} retry = "^0.9.2" diff --git a/service/__init__.py b/service/__init__.py index 0a6999c..631d748 100644 --- a/service/__init__.py +++ b/service/__init__.py @@ -28,7 +28,7 @@ ############################################################ # Initialize the Flask instance ############################################################ -def create_api(): +def create_app(): """Initialize the core application.""" # Create Flask application app = Flask(__name__) @@ -43,6 +43,7 @@ def create_api(): db.init_app(app) # Configure Swagger before initializing it + global api api = Api( app, version="1.0.0", @@ -77,4 +78,4 @@ def create_api(): app.logger.info("Service initialized!") - return api + return app diff --git a/service/common/error_handlers.py b/service/common/error_handlers.py index b2e656f..beddac4 100644 --- a/service/common/error_handlers.py +++ b/service/common/error_handlers.py @@ -16,10 +16,9 @@ """ Module: error_handlers """ -from wsgi import app, api - # from flask import jsonify -# from flask import current_app as app # Import Flask application +from service import api +from flask import current_app as app # Import Flask application from service.models import DataValidationError, DatabaseConnectionError from . import status diff --git a/service/models.py b/service/models.py index 382c690..1d08ba1 100644 --- a/service/models.py +++ b/service/models.py @@ -13,6 +13,8 @@ # Create the SQLAlchemy object to be initialized later in init_db() db = SQLAlchemy() +class DatabaseConnectionError(Exception): + """Custom Exception when database connection fails""" class DataValidationError(Exception): """Used for an data validation errors when deserializing""" diff --git a/service/routes.py b/service/routes.py index 086387d..4ff0cb0 100644 --- a/service/routes.py +++ b/service/routes.py @@ -22,12 +22,11 @@ """ from flask import jsonify, abort - -# from flask import current_app as app # Import Flask application +from flask import current_app as app # Import Flask application from flask_restx import Resource, fields, reqparse from service.models import Inventory, Condition from service.common import status # HTTP Status Codes -from wsgi import app, api +from . import api ###################################################################### diff --git a/service/static/index.html b/service/static/index.html index 1755c66..4fc8ecf 100644 --- a/service/static/index.html +++ b/service/static/index.html @@ -123,6 +123,9 @@

Create, Retrieve, Update, and Delete an Inventory:

+ +

API Documentation is here

+