diff --git a/answerking/settings/base.py b/answerking/settings/base.py index b1040b47..446e1961 100644 --- a/answerking/settings/base.py +++ b/answerking/settings/base.py @@ -2,10 +2,12 @@ Django base settings for answerking project. """ import os - from pathlib import Path + from corsheaders.defaults import default_headers, default_methods +from answerking_app.utils.json404_middleware_config import json404_response + # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent @@ -35,6 +37,7 @@ "answerking_app.apps.AnswerkingAppConfig", "rest_framework", "corsheaders", + "drf_problems", ] MIDDLEWARE = [ @@ -72,7 +75,8 @@ REST_FRAMEWORK = { "DEFAULT_PARSER_CLASSES": [ "rest_framework.parsers.JSONParser", - ] + ], + "EXCEPTION_HANDLER": "drf_problems.exceptions.exception_handler", } # Database @@ -107,6 +111,8 @@ }, ] +# JSON404 middleware +JSON404_DATA_FUNCTION = json404_response # Internationalization # https://docs.djangoproject.com/en/4.0/topics/i18n/ diff --git a/answerking/urls.py b/answerking/urls.py index 497a37cd..678375af 100644 --- a/answerking/urls.py +++ b/answerking/urls.py @@ -7,6 +7,7 @@ path("api/", include("answerking_app.urls.category_urls")), path("api/", include("answerking_app.urls.order_urls")), path("admin/", admin.site.urls), + path("", include("drf_problems.urls")), ] if settings.DEBUG: diff --git a/answerking_app/models/serializers.py b/answerking_app/models/serializers.py index da323518..1edc1df8 100644 --- a/answerking_app/models/serializers.py +++ b/answerking_app/models/serializers.py @@ -151,7 +151,7 @@ def create(self, validated_data: dict) -> Order: ) for order_item in order_items_data: item_data: ItemType = order_item.pop("item") - item: Item = get_object_or_404(Item, pk=item_data["id"]) + item: Item = get_object_or_404(Item, pk=item_data["id"]) # type: ignore[reportTypedDictNotRequiredAccess] if item.retired: continue OrderLine.objects.create(order=order, item=item, **order_item) diff --git a/answerking_app/tests/test_categories.py b/answerking_app/tests/test_categories.py index 069485d2..9e1bcf6c 100644 --- a/answerking_app/tests/test_categories.py +++ b/answerking_app/tests/test_categories.py @@ -1,16 +1,11 @@ from django.db.models.query import QuerySet from django.test import Client, TestCase, TransactionTestCase -from rest_framework.exceptions import ParseError from answerking_app.models.models import Category, Item -from answerking_app.utils.ErrorType import ErrorMessage from answerking_app.utils.model_types import ( CategoryType, DetailError, - IDType, ItemType, - NewCategoryName, - NewCategoryType, ) client = Client() @@ -59,7 +54,6 @@ def test_get_all_without_categories_returns_no_content(self): response = client.get("/api/categories") actual = response.json() - # Assert # Assert self.assertEqual(expected, actual) self.assertEqual(response.status_code, 200) @@ -108,7 +102,7 @@ def test_get_all_with_categories_returns_ok(self): self.assertEqual(expected, actual) self.assertEqual(response.status_code, 200) - def test_get_id_valid_returns_ok(self): + def test_get_valid_id_returns_ok(self): # Arrange expected: CategoryType = { "id": self.test_cat_1.id, @@ -139,20 +133,25 @@ def test_get_id_valid_returns_ok(self): # Act response = client.get(f"/api/categories/{self.test_cat_1.id}") actual = response.json() - # Assert self.assertEqual(expected, actual) self.assertEqual(response.status_code, 200) - def test_get_id_invalid_returns_not_found(self): + def test_get_invalid_id_returns_not_found(self): # Arrange - expected: DetailError = {"detail": "/api/categories/f not found"} + expected: DetailError = { + "detail": "Not Found", + "status": 404, + "title": "Resource not found", + "type": "http://testserver/problems/not_found/", + } # Act response = client.get("/api/categories/f") actual = response.json() # Assert + self.assertIsInstance(actual.pop("traceId"), str) self.assertEqual(expected, actual) self.assertEqual(response.status_code, 404) @@ -169,10 +168,13 @@ def test_post_valid_with_items_returns_ok(self): "calories": self.test_item_3.calories, "retired": False, } - post_data: NewCategoryType = {"name": "Vegetarian", "items": [item]} + post_data: CategoryType = {"name": "Vegetarian", "items": [item]} - expected_id: IDType = {"id": self.test_cat_2.id + 1} - expected: CategoryType = {**post_data, **expected_id, "retired": False} + expected: CategoryType = { + **post_data, + "id": self.test_cat_2.id + 1, + "retired": False, + } # Act response = client.post( @@ -196,9 +198,13 @@ def test_post_valid_with_items_returns_ok(self): def test_post_valid_without_items_returns_ok(self): # Arrange old_list = client.get("/api/categories").json() - post_data: NewCategoryType = {"name": "Gluten Free", "items": []} - expected_id: IDType = {"id": self.test_cat_2.id + 1} - expected: CategoryType = {**post_data, **expected_id, "retired": False} + post_data: CategoryType = {"name": "Gluten Free", "items": []} + + expected: CategoryType = { + **post_data, + "id": self.test_cat_2.id + 1, + "retired": False, + } # Act response = client.post( @@ -218,7 +224,13 @@ def test_post_valid_without_items_returns_ok(self): def test_post_invalid_json_returns_bad_request(self): # Arrange invalid_json_data: str = '{"invalid": }' - expected_json_error: str = "JSON parse error -" + expected: DetailError = { + "detail": "Parsing JSON Error", + "errors": "JSON parse error - Expecting value: line 1 column 13 (char 12)", + "status": 400, + "title": "Invalid input json.", + "type": "http://testserver/problems/error/", + } # Act response = client.post( @@ -229,17 +241,23 @@ def test_post_invalid_json_returns_bad_request(self): actual = response.json() # Assert - self.assertRaises(ParseError) - self.assertIn(expected_json_error, actual["detail"]) + self.assertIsInstance(actual.pop("traceId"), str) + self.assertEqual(expected, actual) self.assertEqual(response.status_code, 400) def test_post_invalid_details_returns_bad_request(self): # Arrange - invalid_post_data: NewCategoryType = { + invalid_post_data: CategoryType = { "name": "Vegetarian%", "items": [], } - expected_failure_error: dict = {"name": ["Enter a valid value."]} + expected: DetailError = { + "detail": "Validation Error", + "errors": {"name": ["Enter a valid value."]}, + "status": 400, + "title": "Invalid input.", + "type": "http://testserver/problems/error/", + } # Act response = client.post( @@ -250,7 +268,8 @@ def test_post_invalid_details_returns_bad_request(self): actual = response.json() # Assert - self.assertEqual(expected_failure_error, actual) + self.assertIsInstance(actual.pop("traceId"), str) + self.assertEqual(expected, actual) self.assertEqual(response.status_code, 400) def test_put_valid_without_items_returns_no_items(self): @@ -259,15 +278,14 @@ def test_put_valid_without_items_returns_no_items(self): f"/api/categories/{self.test_cat_1.id}" ).json() - post_data: NewCategoryName = {"name": "New Name"} - expected_id: IDType = {"id": self.test_cat_1.id} + post_data: CategoryType = {"name": "New Name"} + expected: CategoryType = { **post_data, - **expected_id, + "id": self.test_cat_1.id, "items": [], "retired": False, } - # Act response = client.put( f"/api/categories/{self.test_cat_1.id}", @@ -287,22 +305,34 @@ def test_put_valid_without_items_returns_no_items(self): self.assertEqual(expected, actual) self.assertEqual(response.status_code, 200) - def test_put_invalid_id_returns_bad_request(self): + def test_put_invalid_id_returns_not_found(self): # Arrange - expected: DetailError = {"detail": "/api/categories/f not found"} + expected: DetailError = { + "detail": "Not Found", + "status": 404, + "title": "Resource not found", + "type": "http://testserver/problems/not_found/", + } # Act response = client.get("/api/categories/f") actual = response.json() # Assert + self.assertIsInstance(actual.pop("traceId"), str) self.assertEqual(expected, actual) self.assertEqual(response.status_code, 404) def test_put_invalid_json_returns_bad_request(self): # Arrange invalid_json_data: str = '{"invalid": }' - expected_json_error: str = "JSON parse error -" + expected: DetailError = { + "detail": "Parsing JSON Error", + "errors": "JSON parse error - Expecting value: line 1 column 13 (char 12)", + "status": 400, + "title": "Invalid input json.", + "type": "http://testserver/problems/error/", + } # Act response = client.put( @@ -313,17 +343,23 @@ def test_put_invalid_json_returns_bad_request(self): actual = response.json() # Assert - self.assertRaises(ParseError) - self.assertIn(expected_json_error, actual["detail"]) + self.assertIsInstance(actual.pop("traceId"), str) + self.assertEqual(expected, actual) self.assertEqual(response.status_code, 400) def test_put_invalid_name_returns_bad_request(self): # Arrange - invalid_post_data: NewCategoryType = { + invalid_post_data: CategoryType = { "name": "New Name*", "items": [], } - expected_failure_error: dict = {"name": ["Enter a valid value."]} + expected: DetailError = { + "detail": "Validation Error", + "errors": {"name": ["Enter a valid value."]}, + "status": 400, + "title": "Invalid input.", + "type": "http://testserver/problems/error/", + } # Act response = client.put( @@ -334,10 +370,11 @@ def test_put_invalid_name_returns_bad_request(self): actual = response.json() # Assert - self.assertEqual(expected_failure_error, actual) + self.assertIsInstance(actual.pop("traceId"), str) + self.assertEqual(expected, actual) self.assertEqual(response.status_code, 400) - def test_put_invalid_item_returns_bad_request(self): + def test_put_not_existing_item_returns_not_found(self): # Arrange item: ItemType = { "id": -1, @@ -348,11 +385,16 @@ def test_put_invalid_item_returns_bad_request(self): "calories": self.test_item_1.calories, "retired": False, } - invalid_post_data: NewCategoryType = { + invalid_post_data: CategoryType = { "name": "New Name", "items": [item], } - expected_failure_error: dict = {"detail": "Not found."} + expected: DetailError = { + "detail": "Not Found", + "status": 404, + "title": "Resource not found", + "type": "http://testserver/problems/error/", + } # Act response = client.put( @@ -363,7 +405,8 @@ def test_put_invalid_item_returns_bad_request(self): actual = response.json() # Assert - self.assertEqual(expected_failure_error, actual) + self.assertIsInstance(actual.pop("traceId"), str) + self.assertEqual(expected, actual) self.assertEqual(response.status_code, 404) def test_put_wrong_item_except_id_returns_correct_item_details(self): @@ -377,7 +420,7 @@ def test_put_wrong_item_except_id_returns_correct_item_details(self): "calories": 666, "retired": False, } - invalid_post_data: NewCategoryType = { + invalid_post_data: CategoryType = { "name": "Burgers", "items": [item_with_wrong_info], } @@ -425,13 +468,18 @@ def test_delete_valid_returns_ok(self): def test_delete_invalid_id_returns_not_found(self): # Arrange - expected: DetailError = {"detail": "/api/categories/f not found"} - + expected: DetailError = { + "detail": "Not Found", + "status": 404, + "title": "Resource not found", + "type": "http://testserver/problems/not_found/", + } # Act response = client.delete("/api/categories/f") actual = response.json() # Assert + self.assertIsInstance(actual.pop("traceId"), str) self.assertEqual(expected, actual) self.assertEqual(response.status_code, 404) @@ -477,15 +525,17 @@ def test_put_add_duplicated_item_in_url_to_category_return_400(self): content_type="application/json", ) actual = response.json() - error_msg: ErrorMessage = { - "error": { - "message": "Resource update failure", - "details": "Item already in category", - } + expected: DetailError = { + "detail": "Item is already in the category", + "status": 400, + "title": "A server error occurred.", + "type": "http://testserver/problems/error/", } + # Assert + self.assertIsInstance(actual.pop("traceId"), str) self.assertEqual(response.status_code, 400) - self.assertEqual(actual, error_msg) + self.assertEqual(actual, expected) class CategoryTestsDB(TransactionTestCase): @@ -524,7 +574,7 @@ def tearDown(self): def test_post_duplicated_name_returns_400(self): # Arrange - post_data: NewCategoryType = {"name": "Vegan", "items": []} + post_data: CategoryType = {"name": "Vegan", "items": []} client.post( "/api/categories", post_data, content_type="application/json" ) @@ -534,16 +584,23 @@ def test_post_duplicated_name_returns_400(self): "/api/categories", post_data, content_type="application/json" ) - expected: DetailError = {"detail": "This name already exists"} + expected: DetailError = { + "detail": "This name already exists", + "status": 400, + "title": "A server error occurred.", + "type": "http://testserver/problems/error/", + } + actual = response.json() # Assert + self.assertIsInstance(actual.pop("traceId"), str) self.assertEqual(expected, actual) self.assertEqual(response.status_code, 400) def test_put_duplicated_name_returns_400(self): # Arrange - post_data: NewCategoryType = {"name": "Vegan", "items": []} + post_data: CategoryType = {"name": "Vegan", "items": []} client.post( "/api/categories", post_data, content_type="application/json" @@ -556,8 +613,14 @@ def test_put_duplicated_name_returns_400(self): content_type="application/json", ) actual = response.json() - expected: DetailError = {"detail": "This name already exists"} + expected: DetailError = { + "detail": "This name already exists", + "status": 400, + "title": "A server error occurred.", + "type": "http://testserver/problems/error/", + } # Assert + self.assertIsInstance(actual.pop("traceId"), str) self.assertEqual(expected, actual) self.assertEqual(response.status_code, 400) diff --git a/answerking_app/tests/test_items.py b/answerking_app/tests/test_items.py index 6694be88..28e5061d 100644 --- a/answerking_app/tests/test_items.py +++ b/answerking_app/tests/test_items.py @@ -3,12 +3,7 @@ from rest_framework.exceptions import ParseError from answerking_app.models.models import Item -from answerking_app.utils.model_types import ( - DetailError, - IDType, - ItemType, - NewItemType, -) +from answerking_app.utils.model_types import DetailError, ItemType client = Client() @@ -97,30 +92,40 @@ def test_get_id_valid_returns_ok(self): self.assertEqual(expected, actual) self.assertEqual(response.status_code, 200) - def test_get_id_invalid_returns_not_found(self): + def test_get_invalid_id_returns_not_found(self): # Arrange - expected: DetailError = {"detail": "/api/items/f not found"} + expected: DetailError = { + "detail": "Not Found", + "status": 404, + "title": "Resource not found", + "type": "http://testserver/problems/not_found/", + } # Act response = client.get("/api/items/f") actual = response.json() # Assert + self.assertIsInstance(actual.pop("traceId"), str) self.assertEqual(expected, actual) self.assertEqual(response.status_code, 404) def test_post_valid_returns_ok(self): # Arrange old_list = client.get("/api/items").json() - post_data: NewItemType = { + post_data: ItemType = { "name": "Whopper", "price": "1.50", "description": "desc", "stock": 100, "calories": 100, } - expected_id: IDType = {"id": self.test_item_2.id + 1} - expected: ItemType = {**expected_id, **post_data, "retired": False} + + expected: ItemType = { + "id": self.test_item_2.id + 1, + **post_data, + "retired": False, + } # Act response = client.post( @@ -140,8 +145,12 @@ def test_post_valid_returns_ok(self): def test_post_invalid_json_returns_bad_request(self): # Arrange invalid_json_data: str = '{"invalid": }' - expected_json_error: DetailError = { - "detail": "JSON parse error - Expecting value: line 1 column 13 (char 12)" + expected: DetailError = { + "detail": "Parsing JSON Error", + "errors": "JSON parse error - Expecting value: line 1 column 13 (char 12)", + "status": 400, + "title": "Invalid input json.", + "type": "http://testserver/problems/error/", } # Act @@ -151,21 +160,25 @@ def test_post_invalid_json_returns_bad_request(self): actual = response.json() # Assert - self.assertRaises(ParseError) - self.assertEqual(expected_json_error, actual) + self.assertIsInstance(actual.pop("traceId"), str) + self.assertEqual(expected, actual) self.assertEqual(response.status_code, 400) def test_post_invalid_name_returns_bad_request(self): # Arrange - invalid_post_data: NewItemType = { + invalid_post_data: ItemType = { "name": "Bad data£", "price": "1.50", "description": "desc", "stock": 100, "calories": 100, } - expected_failure_error: dict[str, list[str]] = { - "name": ["Enter a valid value."] + expected: DetailError = { + "detail": "Validation Error", + "errors": {"name": ["Enter a valid value."]}, + "status": 400, + "title": "Invalid input.", + "type": "http://testserver/problems/error/", } # Act @@ -175,20 +188,25 @@ def test_post_invalid_name_returns_bad_request(self): actual = response.json() # Assert - self.assertEqual(expected_failure_error, actual) + self.assertIsInstance(actual.pop("traceId"), str) + self.assertEqual(expected, actual) self.assertEqual(response.status_code, 400) def test_post_invalid_price_returns_bad_request(self): # Arrange - invalid_post_data: NewItemType = { + invalid_post_data: ItemType = { "name": "Bad data", "price": "1.50f", "description": "desc", "stock": 100, "calories": 100, } - expected_failure_error: dict[str, list[str]] = { - "price": ["A valid number is required."] + expected: DetailError = { + "detail": "Validation Error", + "errors": {"price": ["A valid number is required."]}, + "status": 400, + "title": "Invalid input.", + "type": "http://testserver/problems/error/", } # Act @@ -198,20 +216,56 @@ def test_post_invalid_price_returns_bad_request(self): actual = response.json() # Assert - self.assertEqual(expected_failure_error, actual) + self.assertIsInstance(actual.pop("traceId"), str) + self.assertEqual(expected, actual) self.assertEqual(response.status_code, 400) def test_post_invalid_description_returns_bad_request(self): # Arrange - invalid_post_data: NewItemType = { + invalid_post_data: ItemType = { "name": "Bad data", "price": "1.50", "description": "desc&", "stock": 100, "calories": 100, } - expected_failure_error: dict[str, list[str]] = { - "description": ["Enter a valid value."] + expected: DetailError = { + "detail": "Validation Error", + "errors": {"description": ["Enter a valid value."]}, + "status": 400, + "title": "Invalid input.", + "type": "http://testserver/problems/error/", + } + + # Act + response = client.post( + "/api/items", invalid_post_data, content_type="application/json" + ) + actual = response.json() + + # Assert + self.assertIsInstance(actual.pop("traceId"), str) + self.assertEqual(expected, actual) + self.assertEqual(response.status_code, 400) + + def test_post_invalid_stock_and_price_returns_bad_request(self): + # Arrange + invalid_post_data: ItemType = { + "name": "Bad data", + "price": "1.50asd", # type: ignore[reportGeneralTypeIssues] + "description": "desc", + "stock": "f100", # type: ignore[reportGeneralTypeIssues] + "calories": 100, + } + expected: DetailError = { + "detail": "Validation Error", + "errors": { + "stock": ["A valid integer is required."], + "price": ["A valid number is required."], + }, + "status": 400, + "title": "Invalid input.", + "type": "http://testserver/problems/error/", } # Act @@ -221,20 +275,25 @@ def test_post_invalid_description_returns_bad_request(self): actual = response.json() # Assert - self.assertEqual(expected_failure_error, actual) + self.assertIsInstance(actual.pop("traceId"), str) + self.assertEqual(expected, actual) self.assertEqual(response.status_code, 400) def test_post_invalid_stock_returns_bad_request(self): # Arrange - invalid_post_data: NewItemType = { + invalid_post_data: ItemType = { "name": "Bad data", "price": "1.50", "description": "desc", - "stock": "f100", # type: ignore + "stock": "f100", # type: ignore[reportGeneralTypeIssues] "calories": 100, } - expected_failure_error: dict[str, list[str]] = { - "stock": ["A valid integer is required."] + expected: DetailError = { + "detail": "Validation Error", + "errors": {"stock": ["A valid integer is required."]}, + "status": 400, + "title": "Invalid input.", + "type": "http://testserver/problems/error/", } # Act @@ -244,20 +303,27 @@ def test_post_invalid_stock_returns_bad_request(self): actual = response.json() # Assert - self.assertEqual(expected_failure_error, actual) + self.assertIsInstance(actual.pop("traceId"), str) + self.assertEqual(expected, actual) self.assertEqual(response.status_code, 400) def test_post_negative_stock_returns_bad_request(self): # Arrange - invalid_post_data: NewItemType = { + invalid_post_data: ItemType = { "name": "Bad data", "price": "1.50", "description": "desc", "stock": -100, "calories": 100, } - expected_failure_error: dict[str, list[str]] = { - "stock": ["Ensure this value is greater than or equal to 0."] + expected: DetailError = { + "detail": "Validation Error", + "errors": { + "stock": ["Ensure this value is greater than or equal to 0."] + }, + "status": 400, + "title": "Invalid input.", + "type": "http://testserver/problems/error/", } # Act @@ -267,20 +333,25 @@ def test_post_negative_stock_returns_bad_request(self): actual = response.json() # Assert - self.assertEqual(expected_failure_error, actual) + self.assertIsInstance(actual.pop("traceId"), str) + self.assertEqual(expected, actual) self.assertEqual(response.status_code, 400) def test_post_invalid_calories_returns_bad_request(self): # Arrange - invalid_post_data: NewItemType = { + invalid_post_data: ItemType = { "name": "Bad data", "price": "1.50", "description": "desc", "stock": 100, - "calories": "100f", # type: ignore + "calories": "100f", # type: ignore[reportGeneralTypeIssues] } - expected_failure_error: dict[str, list[str]] = { - "calories": ["A valid integer is required."] + expected: DetailError = { + "detail": "Validation Error", + "errors": {"calories": ["A valid integer is required."]}, + "status": 400, + "title": "Invalid input.", + "type": "http://testserver/problems/error/", } # Act @@ -290,20 +361,29 @@ def test_post_invalid_calories_returns_bad_request(self): actual = response.json() # Assert - self.assertEqual(expected_failure_error, actual) + self.assertIsInstance(actual.pop("traceId"), str) + self.assertEqual(expected, actual) self.assertEqual(response.status_code, 400) def test_post_negative_calories_returns_bad_request(self): # Arrange - invalid_post_data: NewItemType = { + invalid_post_data: ItemType = { "name": "Bad data", "price": "1.50", "description": "desc", "stock": 100, "calories": -100, } - expected_failure_error: dict[str, list[str]] = { - "calories": ["Ensure this value is greater than or equal to 0."] + expected: DetailError = { + "detail": "Validation Error", + "errors": { + "calories": [ + "Ensure this value is greater than or equal to 0." + ] + }, + "status": 400, + "title": "Invalid input.", + "type": "http://testserver/problems/error/", } # Act @@ -313,21 +393,25 @@ def test_post_negative_calories_returns_bad_request(self): actual = response.json() # Assert - self.assertEqual(expected_failure_error, actual) + self.assertIsInstance(actual.pop("traceId"), str) + self.assertEqual(expected, actual) self.assertEqual(response.status_code, 400) def test_put_valid_returns_ok(self): # Arrange old_item = client.get(f"/api/items/{self.test_item_1.id}").json() - post_data: NewItemType = { + post_data: ItemType = { "name": "New Burger", "price": "1.75", "description": "new desc", "stock": 0, "calories": 200, } - expected_id: IDType = {"id": self.test_item_1.id} - expected: ItemType = {**expected_id, **post_data, "retired": False} + expected: ItemType = { + "id": self.test_item_1.id, + **post_data, + "retired": False, + } # Act response = client.put( @@ -346,23 +430,15 @@ def test_put_valid_returns_ok(self): self.assertEqual(expected, actual) self.assertEqual(response.status_code, 200) - def test_put_invalid_id_returns_bad_request(self): - # Arrange - expected: DetailError = {"detail": "/api/items/f not found"} - - # Act - response = client.get("/api/items/f") - actual = response.json() - - # Assert - self.assertEqual(expected, actual) - self.assertEqual(response.status_code, 404) - def test_put_invalid_json_returns_bad_request(self): # Arrange invalid_json_data: str = '{"invalid": }' - expected_json_error: DetailError = { - "detail": "JSON parse error - Expecting value: line 1 column 13 (char 12)" + expected: DetailError = { + "detail": "Parsing JSON Error", + "errors": "JSON parse error - Expecting value: line 1 column 13 (char 12)", + "status": 400, + "title": "Invalid input json.", + "type": "http://testserver/problems/error/", } # Act @@ -374,22 +450,28 @@ def test_put_invalid_json_returns_bad_request(self): actual = response.json() # Assert - self.assertRaises(ParseError) - self.assertEqual(expected_json_error, actual) + self.assertIsInstance(actual.pop("traceId"), str) + self.assertEqual(expected, actual) self.assertEqual(response.status_code, 400) def test_put_invalid_details_returns_bad_request(self): # Arrange - invalid_post_data: NewItemType = { + invalid_post_data: ItemType = { "name": "Bad data£", "price": "1.50", "description": "*", "stock": 100, "calories": 100, } - expected_failure_error: dict[str, list[str]] = { - "description": ["Enter a valid value."], - "name": ["Enter a valid value."], + expected: DetailError = { + "detail": "Validation Error", + "errors": { + "description": ["Enter a valid value."], + "name": ["Enter a valid value."], + }, + "status": 400, + "title": "Invalid input.", + "type": "http://testserver/problems/error/", } # Act @@ -401,7 +483,8 @@ def test_put_invalid_details_returns_bad_request(self): actual = response.json() # Assert - self.assertEqual(expected_failure_error, actual) + self.assertIsInstance(actual.pop("traceId"), str) + self.assertEqual(expected, actual) self.assertEqual(response.status_code, 400) def test_delete_valid_returns_retired_true(self): @@ -430,13 +513,19 @@ def test_delete_valid_returns_retired_true(self): def test_delete_invalid_id_returns_not_found(self): # Arrange - expected: DetailError = {"detail": "/api/items/f not found"} + expected: DetailError = { + "detail": "Not Found", + "status": 404, + "title": "Resource not found", + "type": "http://testserver/problems/not_found/", + } # Act response = client.delete("/api/items/f") actual = response.json() # Assert + self.assertIsInstance(actual.pop("traceId"), str) self.assertEqual(expected, actual) self.assertEqual(response.status_code, 404) @@ -463,7 +552,7 @@ def tearDown(self): def test_post_duplicated_name_returns_400(self): # Arrange - post_data: NewItemType = { + post_data: ItemType = { "name": "Whopper", "price": "1.50", "description": "desc", @@ -477,17 +566,24 @@ def test_post_duplicated_name_returns_400(self): "/api/items", post_data, content_type="application/json" ) - expected: DetailError = {"detail": "This name already exists"} + expected: DetailError = { + "detail": "This name already exists", + "status": 400, + "title": "A server error occurred.", + "type": "http://testserver/problems/error/", + } + actual = response.json() # Assert + self.assertIsInstance(actual.pop("traceId"), str) self.assertEqual(expected, actual) self.assertEqual(response.status_code, 400) - def test_put_duplicated_name_returns_404(self): + def test_put_duplicated_name_returns_400(self): # Arrange old_item = client.get(f"/api/items/{self.test_item_1.id}").json() - post_data: NewItemType = { + post_data: ItemType = { "name": "New Burger", "price": "1.75", "description": "new desc", @@ -495,7 +591,7 @@ def test_put_duplicated_name_returns_404(self): "calories": 200, } - post_data_different_name: NewItemType = { + post_data_different_name: ItemType = { **post_data, "name": "Different Name", } @@ -514,8 +610,14 @@ def test_put_duplicated_name_returns_404(self): content_type="application/json", ) actual = response.json() - expected: DetailError = {"detail": "This name already exists"} + expected: DetailError = { + "detail": "This name already exists", + "status": 400, + "title": "A server error occurred.", + "type": "http://testserver/problems/error/", + } # Assert + self.assertIsInstance(actual.pop("traceId"), str) self.assertEqual(expected, actual) self.assertEqual(response.status_code, 400) diff --git a/answerking_app/tests/test_orderlines.py b/answerking_app/tests/test_orderlines.py index fe46c69b..3473ac10 100644 --- a/answerking_app/tests/test_orderlines.py +++ b/answerking_app/tests/test_orderlines.py @@ -1,10 +1,9 @@ from django.test import Client, TestCase from answerking_app.models.models import Item, Order, Status -from answerking_app.utils.ErrorType import ErrorMessage from answerking_app.utils.model_types import ( DetailError, - OrderItemQtyType, + OrderItemType, OrderType, ) @@ -82,7 +81,7 @@ def test_add_new_orderline_valid_returns_ok(self): ], "total": "6.50", } - post_data: OrderItemQtyType = {"quantity": 1} + post_data: OrderItemType = {"quantity": 1} # Act response = client.put( @@ -113,7 +112,7 @@ def test_update_existing_orderline_valid_returns_ok(self): ], "total": "2.50", } - post_data: OrderItemQtyType = {"quantity": 1} + post_data: OrderItemType = {"quantity": 1} # Act response = client.put( @@ -136,7 +135,7 @@ def test_update_existing_orderline_zero_quantity_returns_ok(self): "order_items": [], "total": "0.00", } - post_data: OrderItemQtyType = {"quantity": 0} + post_data: OrderItemType = {"quantity": 0} # Act response = client.put( @@ -152,10 +151,15 @@ def test_update_existing_orderline_zero_quantity_returns_ok(self): def test_update_existing_orderline_invalid_returns_bad_request(self): # Arrange - expected_failure_error: dict[str, list[str]] = { - "quantity": ["A valid integer is required."] + expected: DetailError = { + "detail": "Validation Error", + "errors": {"quantity": ["A valid integer is required."]}, + "status": 400, + "title": "Invalid input.", + "type": "http://testserver/problems/error/", } - post_data: OrderItemQtyType = {"quantity": "f"} # type: ignore + + post_data: OrderItemType = {"quantity": "f"} # type: ignore[reportGeneralTypeIssues] # Act response = client.put( @@ -166,15 +170,24 @@ def test_update_existing_orderline_invalid_returns_bad_request(self): actual = response.json() # Assert - self.assertEqual(expected_failure_error, actual) + self.assertIsInstance(actual.pop("traceId"), str) + self.assertEqual(expected, actual) self.assertEqual(response.status_code, 400) def test_update_existing_orderline_negative_returns_bad_request(self): # Arrange - expected_failure_error: dict[str, list[str]] = { - "quantity": ["Ensure this value is greater than or equal to 0."] + expected: DetailError = { + "detail": "Validation Error", + "errors": { + "quantity": [ + "Ensure this value is greater than or equal to 0." + ] + }, + "status": 400, + "title": "Invalid input.", + "type": "http://testserver/problems/error/", } - post_data: OrderItemQtyType = {"quantity": -1} # type: ignore + post_data: OrderItemType = {"quantity": -1} # Act response = client.put( @@ -185,12 +198,18 @@ def test_update_existing_orderline_negative_returns_bad_request(self): actual = response.json() # Assert - self.assertEqual(expected_failure_error, actual) + self.assertIsInstance(actual.pop("traceId"), str) + self.assertEqual(expected, actual) self.assertEqual(response.status_code, 400) def test_nonexistant_orderid_returns_not_found(self): # Arrange - expected: DetailError = {"detail": "Not found."} + expected: DetailError = { + "detail": "Not Found", + "status": 404, + "title": "Resource not found", + "type": "http://testserver/problems/error/", + } # Act response = client.put( @@ -201,12 +220,18 @@ def test_nonexistant_orderid_returns_not_found(self): actual = response.json() # Assert + self.assertIsInstance(actual.pop("traceId"), str) self.assertEqual(expected, actual) self.assertEqual(response.status_code, 404) def test_nonexistant_itemid_returns_not_found(self): # Arrange - expected: DetailError = {"detail": "Not found."} + expected: DetailError = { + "detail": "Not Found", + "status": 404, + "title": "Resource not found", + "type": "http://testserver/problems/error/", + } # Act response = client.put( @@ -217,13 +242,17 @@ def test_nonexistant_itemid_returns_not_found(self): actual = response.json() # Assert + self.assertIsInstance(actual.pop("traceId"), str) self.assertEqual(expected, actual) self.assertEqual(response.status_code, 404) def test_invalid_orderid_returns_not_found(self): # Arrange expected: DetailError = { - "detail": f"/api/orders/f/orderline/{self.test_item_2.id} not found" + "detail": "Not Found", + "status": 404, + "title": "Resource not found", + "type": "http://testserver/problems/not_found/", } # Act @@ -235,13 +264,17 @@ def test_invalid_orderid_returns_not_found(self): actual = response.json() # Assert + self.assertIsInstance(actual.pop("traceId"), str) self.assertEqual(expected, actual) self.assertEqual(response.status_code, 404) def test_invalid_itemid_returns_not_found(self): # Arrange expected: DetailError = { - "detail": f"/api/orders/{self.test_order_1.id}/orderline/f not found" + "detail": "Not Found", + "status": 404, + "title": "Resource not found", + "type": "http://testserver/problems/not_found/", } # Act @@ -253,6 +286,7 @@ def test_invalid_itemid_returns_not_found(self): actual = response.json() # Assert + self.assertIsInstance(actual.pop("traceId"), str) self.assertEqual(expected, actual) self.assertEqual(response.status_code, 404) @@ -278,11 +312,11 @@ def test_delete_valid_returns_ok(self): def test_delete_nonexistant_id_returns_not_found(self): # Arrange - expected: ErrorMessage = { - "error": { - "message": "Resource update failure", - "details": "Item not in order", - } + expected: DetailError = { + "detail": "A server error occurred.", + "status": 404, + "title": "A server error occurred.", + "type": "http://testserver/problems/error/", } # Act @@ -292,5 +326,6 @@ def test_delete_nonexistant_id_returns_not_found(self): actual = response.json() # Assert + self.assertIsInstance(actual.pop("traceId"), str) self.assertEqual(expected, actual) - self.assertEqual(response.status_code, 400) + self.assertEqual(response.status_code, 404) diff --git a/answerking_app/tests/test_orders.py b/answerking_app/tests/test_orders.py index 1c4c065d..c898f5ab 100644 --- a/answerking_app/tests/test_orders.py +++ b/answerking_app/tests/test_orders.py @@ -1,18 +1,12 @@ from django.db.models import QuerySet -from django.test import TestCase, Client -from rest_framework.exceptions import ParseError +from django.test import Client, TestCase -from answerking_app.models.models import Item, Order, Status, OrderLine +from answerking_app.models.models import Item, Order, OrderLine, Status from answerking_app.utils.model_types import ( - OrderType, - NewOrderAddressType, - OrderItemType, - NewOrderType, - UpdateOrderType, - NewStatusType, DetailError, + OrderItemType, + OrderType, ) -from answerking_app.utils.ErrorType import ErrorMessage client = Client() @@ -159,13 +153,19 @@ def test_get_id_valid_returns_ok(self): def test_get_id_invalid_returns_not_found(self): # Arrange - expected: DetailError = {"detail": "/api/orders/f not found"} + expected: DetailError = { + "detail": "Not Found", + "status": 404, + "title": "Resource not found", + "type": "http://testserver/problems/not_found/", + } # Act response = client.get("/api/orders/f") actual = response.json() # Assert + self.assertIsInstance(actual.pop("traceId"), str) self.assertEqual(expected, actual) self.assertEqual(response.status_code, 404) @@ -173,7 +173,7 @@ def test_post_valid_without_items_returns_ok(self): # Arrange old_list = client.get("/api/orders").json() - post_data: NewOrderAddressType = {"address": "test street 123"} + post_data: OrderType = {"address": "test street 123"} expected: OrderType = { "id": self.test_order_2.id + 1, "status": self.status_pending.status, @@ -203,7 +203,7 @@ def test_post_valid_with_empty_items_returns_ok(self): # Arrange old_list = client.get("/api/orders").json() - post_data: NewOrderType = { + post_data: OrderType = { "address": "test street 123", "order_items": [], } @@ -242,7 +242,7 @@ def test_post_valid_with_items_returns_ok(self): "quantity": 1, "sub_total": f"{self.test_item_3.price:.2f}", } - post_data: NewOrderType = { + post_data: OrderType = { "address": "test street 123", "order_items": [order_item], } @@ -274,7 +274,13 @@ def test_post_valid_with_items_returns_ok(self): def test_post_invalid_json_returns_bad_request(self): # Arrange invalid_json_data: str = '{"invalid": }' - expected_json_error: str = "JSON parse error -" + expected: DetailError = { + "detail": "Parsing JSON Error", + "errors": "JSON parse error - Expecting value: line 1 column 13 (char 12)", + "status": 400, + "title": "Invalid input json.", + "type": "http://testserver/problems/error/", + } # Act response = client.post( @@ -283,15 +289,19 @@ def test_post_invalid_json_returns_bad_request(self): actual = response.json() # Assert - self.assertRaises(ParseError) - self.assertIn(expected_json_error, actual["detail"]) + self.assertIsInstance(actual.pop("traceId"), str) + self.assertEqual(expected, actual) self.assertEqual(response.status_code, 400) def test_post_invalid_details_returns_bad_request(self): # Arrange - invalid_post_data: NewOrderAddressType = {"address": "test%"} - expected_failure_error: dict[str, list[str]] = { - "address": ["Enter a valid value."] + invalid_post_data: OrderType = {"address": "test%"} + expected: DetailError = { + "detail": "Validation Error", + "errors": {"address": ["Enter a valid value."]}, + "status": 400, + "title": "Invalid input.", + "type": "http://testserver/problems/error/", } # Act @@ -301,17 +311,24 @@ def test_post_invalid_details_returns_bad_request(self): actual = response.json() # Assert - self.assertEqual(expected_failure_error, actual) + self.assertIsInstance(actual.pop("traceId"), str) + self.assertEqual(expected, actual) self.assertEqual(response.status_code, 400) def test_post_invalid_items_returns_bad_request(self): # Arrange - invalid_post_data: NewOrderType = { + invalid_post_data: OrderType = { "address": "test", - "order_items": [{"values": "invalid"}], # type: ignore + "order_items": [{"values": "invalid"}], # type: ignore[reportGeneralTypeIssues] } - expected_failure_error: dict[str, list[dict[str, list[str]]]] = { - "order_items": [{"quantity": ["This field is required."]}] + expected: DetailError = { + "detail": "Validation Error", + "errors": { + "order_items": [{"quantity": ["This field is required."]}] + }, + "status": 400, + "title": "Invalid input.", + "type": "http://testserver/problems/error/", } # Act @@ -321,13 +338,14 @@ def test_post_invalid_items_returns_bad_request(self): actual = response.json() # Assert - self.assertEqual(expected_failure_error, actual) + self.assertIsInstance(actual.pop("traceId"), str) + self.assertEqual(expected, actual) self.assertEqual(response.status_code, 400) def test_put_valid_address_and_status_returns_ok(self): # Arrange old_order = client.get(f"/api/orders/{self.test_order_1.id}").json() - post_data: UpdateOrderType = { + post_data: OrderType = { "address": "test", "status": self.status_complete.status, } @@ -373,7 +391,7 @@ def test_put_valid_address_and_status_returns_ok(self): def test_put_valid_address_returns_ok(self): # Arrange old_order = client.get(f"/api/orders/{self.test_order_1.id}").json() - post_data: NewOrderAddressType = {"address": "test"} + post_data: OrderType = {"address": "test"} expected: OrderType = { "id": self.test_order_1.id, **post_data, @@ -417,7 +435,9 @@ def test_put_valid_address_returns_ok(self): def test_put_valid_status_returns_ok(self): # Arrange old_order = client.get(f"/api/orders/{self.test_order_1.id}").json() - post_data: NewStatusType = {"status": self.status_complete.status} + post_data: OrderType = { + "status": self.status_complete.status, + } expected: OrderType = { "id": self.test_order_1.id, "address": self.test_order_1.address, @@ -460,9 +480,13 @@ def test_put_valid_status_returns_ok(self): def test_put_invalid_address_returns_bad_request(self): # Arrange - invalid_post_data: NewOrderAddressType = {"address": "test&"} - expected_failure_error: dict[str, list[str]] = { - "address": ["Enter a valid value."] + invalid_post_data: OrderType = {"address": "test&"} + expected: DetailError = { + "detail": "Validation Error", + "errors": {"address": ["Enter a valid value."]}, + "status": 400, + "title": "Invalid input.", + "type": "http://testserver/problems/error/", } # Act @@ -474,20 +498,22 @@ def test_put_invalid_address_returns_bad_request(self): actual = response.json() # Assert - self.assertEqual(expected_failure_error, actual) + self.assertIsInstance(actual.pop("traceId"), str) + self.assertEqual(expected, actual) self.assertEqual(response.status_code, 400) - def test_put_invalid_status_returns_bad_request(self): + def test_put_invalid_status_returns_not_found(self): # Arrange - invalid_post_data: UpdateOrderType = { + invalid_post_data: OrderType = { "address": "test", "status": "invalid", } - expected_failure_error: ErrorMessage = { - "error": { - "message": "Request failed", - "details": "Object could not be updated", - } + expected: DetailError = { + "detail": "Object was not Found", + "errors": ["Status matching query does not exist."], + "status": 404, + "title": "Resource not found", + "type": "http://testserver/problems/error/", } # Act @@ -499,8 +525,9 @@ def test_put_invalid_status_returns_bad_request(self): actual = response.json() # Assert - self.assertEqual(expected_failure_error, actual) - self.assertEqual(response.status_code, 400) + self.assertIsInstance(actual.pop("traceId"), str) + self.assertEqual(expected, actual) + self.assertEqual(response.status_code, 404) def test_delete_valid_returns_ok(self): # Arrange @@ -516,12 +543,18 @@ def test_delete_valid_returns_ok(self): def test_delete_invalid_id_returns_not_found(self): # Arrange - expected: DetailError = {"detail": "/api/orders/f not found"} + expected: DetailError = { + "detail": "Not Found", + "status": 404, + "title": "Resource not found", + "type": "http://testserver/problems/not_found/", + } # Act response = client.delete("/api/orders/f") actual = response.json() # Assert + self.assertIsInstance(actual.pop("traceId"), str) self.assertEqual(expected, actual) self.assertEqual(response.status_code, 404) diff --git a/answerking_app/utils/json404_middleware_config.py b/answerking_app/utils/json404_middleware_config.py new file mode 100644 index 00000000..d5d5d119 --- /dev/null +++ b/answerking_app/utils/json404_middleware_config.py @@ -0,0 +1,17 @@ +import uuid + +from django.http import JsonResponse + + +def json404_response(request): + data = { + "detail": "Not Found", + "title": "Resource not found", + "status": 404, + "type": "{}://{}/problems/not_found/", + "traceId": uuid.uuid4(), + } + data["type"] = data["type"].format(request.scheme, request.get_host()) + return JsonResponse( + data, content_type="application/problem+json", status=404 + ) diff --git a/answerking_app/utils/mixins/ApiExceptions.py b/answerking_app/utils/mixins/ApiExceptions.py new file mode 100644 index 00000000..14853224 --- /dev/null +++ b/answerking_app/utils/mixins/ApiExceptions.py @@ -0,0 +1,23 @@ +import uuid + +from rest_framework.exceptions import APIException + + +class HttpErrorResponse(APIException): + def __init__( + self, + status: int, + detail: str | None = None, + title: str | None = None, + instance: str | None = None, + extensions: dict = {}, + ): + super().__init__() + self.status_code = status + if detail: + self.detail = detail + if title: + self.title = title + if instance: + self.instance = instance + self.extensions = extensions | {"traceId": uuid.uuid4()} diff --git a/answerking_app/utils/mixins/CategoryItemMixins.py b/answerking_app/utils/mixins/CategoryItemMixins.py index 6e6b5a95..0e36abb8 100644 --- a/answerking_app/utils/mixins/CategoryItemMixins.py +++ b/answerking_app/utils/mixins/CategoryItemMixins.py @@ -1,14 +1,12 @@ -from django.db import IntegrityError +from django.shortcuts import get_object_or_404 from rest_framework import status -from rest_framework.response import Response from rest_framework.request import Request +from rest_framework.response import Response from rest_framework.utils.serializer_helpers import ReturnDict from answerking_app.models.models import Category, Item from answerking_app.models.serializers import CategorySerializer -from answerking_app.utils.ErrorType import ErrorMessage - -from django.shortcuts import get_object_or_404 +from answerking_app.utils.mixins.ApiExceptions import HttpErrorResponse class CategoryItemUpdateMixin: @@ -20,15 +18,9 @@ def update( item: Item = get_object_or_404(Item, pk=item_id) if item in category.items.all(): - error_msg: ErrorMessage = { - "error": { - "message": "Resource update failure", - "details": "Item already in category", - } - } - return Response( - error_msg, + raise HttpErrorResponse( status=status.HTTP_400_BAD_REQUEST, + detail="Item is already in the category", ) category.items.add(item) diff --git a/answerking_app/utils/mixins/GenericMixins.py b/answerking_app/utils/mixins/IntegrityHandlerMixins.py similarity index 56% rename from answerking_app/utils/mixins/GenericMixins.py rename to answerking_app/utils/mixins/IntegrityHandlerMixins.py index f76b6cea..a9443804 100644 --- a/answerking_app/utils/mixins/GenericMixins.py +++ b/answerking_app/utils/mixins/IntegrityHandlerMixins.py @@ -1,38 +1,36 @@ -from MySQLdb.constants.ER import DUP_ENTRY +from typing import NoReturn + from django.db import IntegrityError +from MySQLdb.constants.ER import DUP_ENTRY from rest_framework import status from rest_framework.mixins import CreateModelMixin, UpdateModelMixin from rest_framework.request import Request from rest_framework.response import Response +from answerking_app.utils.mixins.ApiExceptions import HttpErrorResponse -class CreateMixin(CreateModelMixin): + +class CreateIntegrityHandlerMixin(CreateModelMixin): def create(self, request: Request, *args, **kwargs) -> Response: try: return super().create(request, *args, **kwargs) except IntegrityError as exc: - return duplicate_check(exc) + handle_IntegrityError(exc) -class UpdateMixin(UpdateModelMixin): +class UpdateIntegrityHandlerMixin(UpdateModelMixin): def update(self, request: Request, *args, **kwargs) -> Response: try: return super().update(request, *args, **kwargs) except IntegrityError as exc: - return duplicate_check(exc) - - -class RetireMixin(UpdateModelMixin): - def retire(self, request: Request, *args, **kwargs) -> Response: - request.data["retired"] = True - return super().partial_update(request, *args, **kwargs) + return handle_IntegrityError(exc) -def duplicate_check(exc: IntegrityError) -> Response: +def handle_IntegrityError(exc: IntegrityError) -> NoReturn: if exc.args[0] == DUP_ENTRY: - return Response( - {"detail": "This name already exists"}, + raise HttpErrorResponse( status=status.HTTP_400_BAD_REQUEST, + detail="This name already exists", ) else: - return Response(status=status.HTTP_503_SERVICE_UNAVAILABLE) + raise HttpErrorResponse(status=status.HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/answerking_app/utils/mixins/ItemMixins.py b/answerking_app/utils/mixins/ItemMixins.py index 75e97e3e..dcc17f79 100644 --- a/answerking_app/utils/mixins/ItemMixins.py +++ b/answerking_app/utils/mixins/ItemMixins.py @@ -3,7 +3,8 @@ from rest_framework.request import Request from rest_framework.response import Response -from answerking_app.models.models import OrderLine, Item +from answerking_app.models.models import Item, OrderLine +from answerking_app.utils.mixins.ApiExceptions import HttpErrorResponse from answerking_app.utils.model_types import OrderItemType @@ -14,10 +15,7 @@ def destroy(self, request: Request, *args, **kwargs) -> Response: item=instance.id ) if existing_orderitems: - return Response( - {"detail": "Cannot delete, item is in an order."}, - status=status.HTTP_400_BAD_REQUEST, - ) + raise HttpErrorResponse(status=status.HTTP_400_BAD_REQUEST) self.perform_destroy(instance) return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/answerking_app/utils/mixins/NotFoundDetailMixins.py b/answerking_app/utils/mixins/NotFoundDetailMixins.py new file mode 100644 index 00000000..66fa89fd --- /dev/null +++ b/answerking_app/utils/mixins/NotFoundDetailMixins.py @@ -0,0 +1,42 @@ +from django.core.exceptions import ObjectDoesNotExist +from django.http import Http404 +from rest_framework import status +from rest_framework.mixins import RetrieveModelMixin, UpdateModelMixin +from rest_framework.request import Request +from rest_framework.response import Response + +from answerking_app.utils.mixins.ApiExceptions import HttpErrorResponse + + +def NotFoundErrorDetailed(exc: Http404 | ObjectDoesNotExist): + if isinstance(exc, ObjectDoesNotExist): + return HttpErrorResponse( + status=status.HTTP_404_NOT_FOUND, + detail="Object was not Found", + title="Resource not found", + extensions={"errors": exc.args}, + ) + elif isinstance(exc, Http404): + return HttpErrorResponse( + status=status.HTTP_404_NOT_FOUND, + detail="Not Found", + title="Resource not found", + ) + else: + return HttpErrorResponse(status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +class GetNotFoundDetailMixin(RetrieveModelMixin): + def retrieve(self, request: Request, *args, **kwargs) -> Response: + try: + return super().retrieve(request, *args, **kwargs) + except (ObjectDoesNotExist, Http404) as exc: + raise NotFoundErrorDetailed(exc) + + +class UpdateNotFoundDetailMixin(UpdateModelMixin): + def update(self, request: Request, *args, **kwargs) -> Response: + try: + return super().update(request, *args, **kwargs) + except (ObjectDoesNotExist, Http404) as exc: + raise NotFoundErrorDetailed(exc) diff --git a/answerking_app/utils/mixins/OrderItemMixins.py b/answerking_app/utils/mixins/OrderItemMixins.py index e5286001..dfcbac9f 100644 --- a/answerking_app/utils/mixins/OrderItemMixins.py +++ b/answerking_app/utils/mixins/OrderItemMixins.py @@ -1,19 +1,19 @@ +from django.shortcuts import get_object_or_404 from rest_framework import status -from rest_framework.response import Response +from rest_framework.mixins import UpdateModelMixin from rest_framework.request import Request +from rest_framework.response import Response from rest_framework.utils.serializer_helpers import ReturnDict -from answerking_app.models.models import Order, Item +from answerking_app.models.models import Item, Order from answerking_app.models.serializers import ( - OrderSerializer, OrderLineSerializer, + OrderSerializer, ) -from answerking_app.utils.ErrorType import ErrorMessage - -from django.shortcuts import get_object_or_404 +from answerking_app.utils.mixins.ApiExceptions import HttpErrorResponse -class OrderItemUpdateMixin: +class OrderItemUpdateMixin(UpdateModelMixin): def update( self, request: Request, order_id: int, item_id: int, *args, **kwargs ) -> Response | None: @@ -59,16 +59,7 @@ def remove( item: Item = get_object_or_404(Item, pk=item_id) if item not in order.order_items.all(): - error_msg: ErrorMessage = { - "error": { - "message": "Resource update failure", - "details": "Item not in order", - } - } - return Response( - error_msg, - status=status.HTTP_400_BAD_REQUEST, - ) + raise HttpErrorResponse(status=status.HTTP_404_NOT_FOUND) updated_order: Order | None = self.remove_item(order, item) diff --git a/answerking_app/utils/mixins/RetireMixin.py b/answerking_app/utils/mixins/RetireMixin.py new file mode 100644 index 00000000..6c560842 --- /dev/null +++ b/answerking_app/utils/mixins/RetireMixin.py @@ -0,0 +1,9 @@ +from rest_framework.mixins import UpdateModelMixin +from rest_framework.request import Request +from rest_framework.response import Response + + +class RetireMixin(UpdateModelMixin): + def retire(self, request: Request, *args, **kwargs) -> Response: + request.data["retired"] = True + return super().partial_update(request, *args, **kwargs) diff --git a/answerking_app/utils/mixins/SerializeErrorDetailRFCMixins.py b/answerking_app/utils/mixins/SerializeErrorDetailRFCMixins.py new file mode 100644 index 00000000..72363ed0 --- /dev/null +++ b/answerking_app/utils/mixins/SerializeErrorDetailRFCMixins.py @@ -0,0 +1,43 @@ +from rest_framework import status +from rest_framework.exceptions import ParseError +from rest_framework.mixins import CreateModelMixin, UpdateModelMixin +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.serializers import ValidationError + +from answerking_app.utils.mixins.ApiExceptions import HttpErrorResponse + + +def ValidationErrorDetailed(exc: ValidationError | ParseError): + if isinstance(exc, ValidationError): + return HttpErrorResponse( + status=status.HTTP_400_BAD_REQUEST, + detail="Validation Error", + title="Invalid input.", + extensions={"errors": exc.detail}, + ) + elif isinstance(exc, ParseError): + return HttpErrorResponse( + status=status.HTTP_400_BAD_REQUEST, + detail="Parsing JSON Error", + title="Invalid input json.", + extensions={"errors": exc.detail}, + ) + else: + return HttpErrorResponse(status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +class CreateErrorDetailMixin(CreateModelMixin): + def create(self, request: Request, *args, **kwargs) -> Response: + try: + return super().create(request, *args, **kwargs) + except (ValidationError, ParseError) as exc: + raise ValidationErrorDetailed(exc) + + +class UpdateErrorDetailMixin(UpdateModelMixin): + def update(self, request: Request, *args, **kwargs) -> Response: + try: + return super().update(request, *args, **kwargs) + except (ValidationError, ParseError) as exc: + raise ValidationErrorDetailed(exc) diff --git a/answerking_app/utils/model_types.py b/answerking_app/utils/model_types.py index 62d82c3b..cf9b2028 100644 --- a/answerking_app/utils/model_types.py +++ b/answerking_app/utils/model_types.py @@ -1,73 +1,49 @@ -from typing import TypedDict, Any +from typing import Any +from typing_extensions import ( # for Python <3.11 with (Not)Required + NotRequired, + TypedDict, +) -class IDType(TypedDict): - id: int - -class NewItemType(TypedDict): +class ItemType(TypedDict): + id: NotRequired[int] name: str price: str description: str + retired: NotRequired[bool] stock: int calories: int -class ItemType(IDType, NewItemType): - retired: bool - - -class NewCategoryName(TypedDict): - name: str - - -class NewCategoryItems(TypedDict): - items: list[ItemType] - - -class NewCategoryType(NewCategoryName, NewCategoryItems): - pass - - -class CategoryType(IDType, NewCategoryType): - retired: bool +class CategoryType(TypedDict): + id: NotRequired[int] + name: NotRequired[str] + retired: NotRequired[bool] + items: NotRequired["list[ItemType]"] class OrderItemType(TypedDict): - id: int - name: str - price: str + id: NotRequired[int] + name: NotRequired[str] + price: NotRequired[str] quantity: int - sub_total: str - + sub_total: NotRequired[str] -class NewOrderAddressType(TypedDict): - address: str - -class NewOrderType(NewOrderAddressType, TypedDict): - order_items: list[OrderItemType] - - -class NewStatusType(TypedDict): - status: str - - -class UpdateOrderType(NewOrderAddressType, NewStatusType): - pass - - -class OrderType(IDType, NewOrderType, NewStatusType, TypedDict): - total: str - - -class OrderItemQtyType(TypedDict): - quantity: int +class OrderType(TypedDict): + id: NotRequired[int] + address: NotRequired[str] + order_items: NotRequired["list[OrderItemType]"] + status: NotRequired[str] + total: NotRequired[str] class DetailError(TypedDict): - detail: str - - -class QuantityError(TypedDict): - quantity: list + detail: NotRequired[str] + type: str + title: str + instance: NotRequired[str] + errors: NotRequired["str | list[Any] | dict[Any, Any]"] + status: NotRequired[int] + traceID: NotRequired[str] diff --git a/answerking_app/views/category_views.py b/answerking_app/views/category_views.py index 66aeeb8a..6fd9c951 100644 --- a/answerking_app/views/category_views.py +++ b/answerking_app/views/category_views.py @@ -1,27 +1,34 @@ from django.db.models import QuerySet - -from rest_framework import mixins, generics +from rest_framework import generics, mixins from rest_framework.request import Request from rest_framework.response import Response +from answerking_app.models.models import Category +from answerking_app.models.serializers import CategorySerializer from answerking_app.utils.mixins.CategoryItemMixins import ( - CategoryItemUpdateMixin, CategoryItemRemoveMixin, + CategoryItemUpdateMixin, ) -from answerking_app.models.models import Category - -from answerking_app.models.serializers import ( - CategorySerializer, +from answerking_app.utils.mixins.IntegrityHandlerMixins import ( + CreateIntegrityHandlerMixin, + UpdateIntegrityHandlerMixin, ) -from answerking_app.utils.mixins.GenericMixins import ( - CreateMixin, - UpdateMixin, - RetireMixin, +from answerking_app.utils.mixins.NotFoundDetailMixins import ( + GetNotFoundDetailMixin, + UpdateNotFoundDetailMixin, +) +from answerking_app.utils.mixins.RetireMixin import RetireMixin +from answerking_app.utils.mixins.SerializeErrorDetailRFCMixins import ( + CreateErrorDetailMixin, + UpdateErrorDetailMixin, ) class CategoryListView( - mixins.ListModelMixin, CreateMixin, generics.GenericAPIView + mixins.ListModelMixin, + CreateIntegrityHandlerMixin, + CreateErrorDetailMixin, + generics.GenericAPIView, ): queryset: QuerySet = Category.objects.all() serializer_class: CategorySerializer = CategorySerializer @@ -34,9 +41,11 @@ def post(self, request: Request, *args, **kwargs) -> Response: class CategoryDetailView( - mixins.RetrieveModelMixin, - UpdateMixin, RetireMixin, + GetNotFoundDetailMixin, + UpdateNotFoundDetailMixin, + UpdateIntegrityHandlerMixin, + UpdateErrorDetailMixin, mixins.DestroyModelMixin, generics.GenericAPIView, ): @@ -54,7 +63,9 @@ def delete(self, request: Request, *args, **kwargs) -> Response: class CategoryItemListView( - CategoryItemUpdateMixin, CategoryItemRemoveMixin, generics.GenericAPIView + CategoryItemUpdateMixin, + CategoryItemRemoveMixin, + generics.GenericAPIView, ): queryset: QuerySet = Category.objects.all() serializer_class: CategorySerializer = CategorySerializer diff --git a/answerking_app/views/item_views.py b/answerking_app/views/item_views.py index 45a4eb17..578f740d 100644 --- a/answerking_app/views/item_views.py +++ b/answerking_app/views/item_views.py @@ -1,20 +1,31 @@ from django.db.models import QuerySet -from rest_framework import mixins, generics +from rest_framework import generics, mixins from rest_framework.request import Request from rest_framework.response import Response -from answerking_app.utils.mixins.GenericMixins import ( - CreateMixin, - UpdateMixin, - RetireMixin, -) -from answerking_app.utils.mixins.ItemMixins import DestroyItemMixin from answerking_app.models.models import Item from answerking_app.models.serializers import ItemSerializer +from answerking_app.utils.mixins.IntegrityHandlerMixins import ( + CreateIntegrityHandlerMixin, + UpdateIntegrityHandlerMixin, +) +from answerking_app.utils.mixins.ItemMixins import DestroyItemMixin +from answerking_app.utils.mixins.NotFoundDetailMixins import ( + GetNotFoundDetailMixin, + UpdateNotFoundDetailMixin, +) +from answerking_app.utils.mixins.RetireMixin import RetireMixin +from answerking_app.utils.mixins.SerializeErrorDetailRFCMixins import ( + CreateErrorDetailMixin, + UpdateErrorDetailMixin, +) class ItemListView( - mixins.ListModelMixin, CreateMixin, generics.GenericAPIView + mixins.ListModelMixin, + CreateErrorDetailMixin, + CreateIntegrityHandlerMixin, + generics.GenericAPIView, ): queryset: QuerySet = Item.objects.all() @@ -28,9 +39,11 @@ def post(self, request: Request, *args, **kwargs) -> Response: class ItemDetailView( - mixins.RetrieveModelMixin, - UpdateMixin, RetireMixin, + GetNotFoundDetailMixin, + UpdateNotFoundDetailMixin, + UpdateIntegrityHandlerMixin, + UpdateErrorDetailMixin, DestroyItemMixin, generics.GenericAPIView, ): diff --git a/answerking_app/views/order_views.py b/answerking_app/views/order_views.py index 569a5060..94afab4d 100644 --- a/answerking_app/views/order_views.py +++ b/answerking_app/views/order_views.py @@ -2,23 +2,32 @@ from django.core.exceptions import ObjectDoesNotExist from django.db.models import QuerySet - -from rest_framework import mixins, generics, status +from rest_framework import generics, mixins, status from rest_framework.request import Request from rest_framework.response import Response -from answerking_app.utils.mixins.OrderItemMixins import ( - OrderItemUpdateMixin, - OrderItemRemoveMixin, -) - from answerking_app.models.models import Order from answerking_app.models.serializers import OrderSerializer from answerking_app.utils.ErrorType import ErrorMessage +from answerking_app.utils.mixins.NotFoundDetailMixins import ( + GetNotFoundDetailMixin, + UpdateNotFoundDetailMixin, +) +from answerking_app.utils.mixins.OrderItemMixins import ( + OrderItemRemoveMixin, + OrderItemUpdateMixin, +) +from answerking_app.utils.mixins.SerializeErrorDetailRFCMixins import ( + CreateErrorDetailMixin, + UpdateErrorDetailMixin, +) class OrderListView( - mixins.ListModelMixin, mixins.CreateModelMixin, generics.GenericAPIView + mixins.ListModelMixin, + CreateErrorDetailMixin, + mixins.CreateModelMixin, + generics.GenericAPIView, ): queryset: QuerySet = Order.objects.all() serializer_class: OrderSerializer = OrderSerializer @@ -31,9 +40,10 @@ def post(self, request: Request, *args, **kwargs) -> Response: class OrderDetailView( - mixins.RetrieveModelMixin, + GetNotFoundDetailMixin, + UpdateNotFoundDetailMixin, + UpdateErrorDetailMixin, mixins.DestroyModelMixin, - mixins.UpdateModelMixin, generics.GenericAPIView, ): @@ -45,28 +55,22 @@ def get(self, request: Request, *args, **kwargs) -> Response: return self.retrieve(request, *args, **kwargs) def put(self, request: Request, *args, **kwargs) -> Response: - try: - return self.partial_update(request, *args, **kwargs) - except (KeyError, ObjectDoesNotExist): - error_msg: ErrorMessage = { - "error": { - "message": "Request failed", - "details": "Object could not be updated", - } - } - return Response( - error_msg, - status=status.HTTP_400_BAD_REQUEST, - ) + return self.partial_update(request, *args, **kwargs) def delete(self, request: Request, *args, **kwargs) -> Response: return self.destroy(request, *args, **kwargs) class OrderItemListView( - OrderItemUpdateMixin, OrderItemRemoveMixin, generics.GenericAPIView + GetNotFoundDetailMixin, + UpdateErrorDetailMixin, + UpdateNotFoundDetailMixin, + OrderItemUpdateMixin, + OrderItemRemoveMixin, + generics.GenericAPIView, ): serializer_class: OrderSerializer = OrderSerializer + lookup_url_kwarg: Literal["order_id"] = "order_id" def put( self, request: Request, order_id: int, item_id: int, *args, **kwargs diff --git a/pyproject.toml b/pyproject.toml index 17ccbd7b..579f1cd8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ version = "0.1.0" description = "" authors = [] readme = "README.md" -packages = [{include = "answerking_python"}] +packages = [{ include = "answerking_python" }] [tool.poetry.dependencies] python = "^3.10" @@ -13,8 +13,10 @@ djangorestframework = "^3.14.0" mysqlclient = "^2.1.1" django-cors-headers = "^3.13.0" drf-writable-nested = "^0.7.0" -django-json-404-middleware = "^0.0.1" +django-json-404-middleware = { git = "https://github.com/Axeltherabbit/django-json-404-middleware" } python-dotenv = "^0.21.0" +drf-problems = { git = "https://github.com/Axeltherabbit/drf-problems" } +typing-extensions = "^4.4.0" [tool.poetry.group.dev.dependencies] black = "^22.10.0"