diff --git a/product/crud/domain_logic/create_product.py b/product/crud/domain_logic/create_product.py index de00e79..63cd1b2 100644 --- a/product/crud/domain_logic/create_product.py +++ b/product/crud/domain_logic/create_product.py @@ -1,16 +1,31 @@ +from aws_lambda_env_modeler import get_environment_variables +from aws_lambda_powertools.utilities.idempotency import DynamoDBPersistenceLayer, IdempotencyConfig, idempotent_function +from aws_lambda_powertools.utilities.idempotency.serialization.pydantic import PydanticSerializer + +from product.crud.handlers.schemas.env_vars import Idempotency from product.crud.handlers.utils.observability import logger, tracer from product.crud.integration import get_dal_handler from product.crud.integration.db_handler import DbHandler -from product.crud.integration.schemas.db import Product from product.crud.schemas.output import CreateProductOutput +from product.models.products.product import Product + +IDEMPOTENCY_LAYER = DynamoDBPersistenceLayer(table_name=get_environment_variables(model=Idempotency).IDEMPOTENCY_TABLE_NAME) +IDEMPOTENCY_CONFIG = IdempotencyConfig( + expires_after_seconds=60, # 1 minute +) +@idempotent_function( + data_keyword_argument='product', + config=IDEMPOTENCY_CONFIG, + persistence_store=IDEMPOTENCY_LAYER, + output_serializer=PydanticSerializer, +) @tracer.capture_method(capture_response=False) -def create_product(product_id: str, product_name: str, product_price: int, table_name: str) -> CreateProductOutput: +def create_product(product: Product, table_name: str) -> CreateProductOutput: logger.info('handling create product request') dal_handler: DbHandler = get_dal_handler(table_name) - product = Product(id=product_id, name=product_name, price=product_price) dal_handler.create_product(product=product) # convert from db entry to output, they won't always be the same logger.info('created product successfully') diff --git a/product/crud/handlers/handle_create_product.py b/product/crud/handlers/handle_create_product.py index 9f5d5a5..d16c7de 100644 --- a/product/crud/handlers/handle_create_product.py +++ b/product/crud/handlers/handle_create_product.py @@ -13,6 +13,7 @@ from product.crud.handlers.utils.rest_api_resolver import app from product.crud.schemas.input import CreateProductInput from product.crud.schemas.output import CreateProductOutput +from product.models.products.product import Product @app.route(PRODUCT_PATH, method=HTTPMethod.PUT) @@ -28,9 +29,11 @@ def handle_create_product(product_id: str) -> dict[str, Any]: metrics.add_metric(name='CreateProductEvents', unit=MetricUnit.Count, value=1) response: CreateProductOutput = create_product( - product_id=product_id, - product_name=create_input.body.name, - product_price=create_input.body.price, + product=Product( + id=product_id, + name=create_input.body.name, + price=create_input.body.price, + ), table_name=env_vars.TABLE_NAME, ) diff --git a/product/crud/handlers/handle_delete_product.py b/product/crud/handlers/handle_delete_product.py index 8aa5988..16574b0 100644 --- a/product/crud/handlers/handle_delete_product.py +++ b/product/crud/handlers/handle_delete_product.py @@ -1,4 +1,4 @@ -from http import HTTPMethod, HTTPStatus +from http import HTTPStatus from aws_lambda_env_modeler import get_environment_variables, init_environment_variables from aws_lambda_powertools.logging import correlation_paths diff --git a/product/crud/handlers/handle_get_product.py b/product/crud/handlers/handle_get_product.py index e1e259a..ee1a339 100644 --- a/product/crud/handlers/handle_get_product.py +++ b/product/crud/handlers/handle_get_product.py @@ -1,4 +1,3 @@ -from http import HTTPMethod from typing import Any from aws_lambda_env_modeler import get_environment_variables, init_environment_variables diff --git a/product/crud/handlers/handle_list_products.py b/product/crud/handlers/handle_list_products.py index 6c2dd83..569d2c3 100644 --- a/product/crud/handlers/handle_list_products.py +++ b/product/crud/handlers/handle_list_products.py @@ -1,4 +1,3 @@ -from http import HTTPMethod from typing import Any from aws_lambda_env_modeler import get_environment_variables, init_environment_variables diff --git a/product/crud/integration/dynamo_db_handler.py b/product/crud/integration/dynamo_db_handler.py index 0430935..2bd3fad 100644 --- a/product/crud/integration/dynamo_db_handler.py +++ b/product/crud/integration/dynamo_db_handler.py @@ -12,10 +12,6 @@ from product.crud.integration.schemas.db import Product, ProductEntries from product.crud.schemas.exceptions import InternalServerException, ProductAlreadyExistsException, ProductNotFoundException -# from product.crud.dal.idempotency import IDEMPOTENCY_CONFIG, IDEMPOTENCY_LAYER -# from aws_lambda_powertools.utilities.idempotency.serialization.pydantic import PydanticSerializer -# from aws_lambda_powertools.utilities.idempotency import idempotent_function - class DynamoDbHandler(DbHandler): @@ -29,12 +25,6 @@ def _get_db_handler(self, table_name: str) -> Table: dynamodb: DynamoDBServiceResource = boto3.resource('dynamodb') return dynamodb.Table(table_name) - # @idempotent_function( - # data_keyword_argument='product', - # config=IDEMPOTENCY_CONFIG, - # persistence_store=IDEMPOTENCY_LAYER, - # output_serializer=PydanticSerializer, - # ) @tracer.capture_method(capture_response=False) def create_product(self, product: Product) -> None: logger.info('trying to create a product') diff --git a/product/crud/integration/idempotency.py b/product/crud/integration/idempotency.py deleted file mode 100644 index fa81177..0000000 --- a/product/crud/integration/idempotency.py +++ /dev/null @@ -1,10 +0,0 @@ -from aws_lambda_env_modeler import get_environment_variables # pragma: no cover -from aws_lambda_powertools.utilities.idempotency import DynamoDBPersistenceLayer, IdempotencyConfig # pragma: no cover - -from product.crud.handlers.schemas.env_vars import Idempotency # pragma: no cover - -IDEMPOTENCY_LAYER = DynamoDBPersistenceLayer(table_name=get_environment_variables(model=Idempotency).IDEMPOTENCY_TABLE_NAME) # pragma: no cover -IDEMPOTENCY_CONFIG = IdempotencyConfig( - expires_after_seconds=60, # 1 minute - event_key_jmespath='["pathParameters"]', -) # pragma: no cover diff --git a/product/crud/integration/schemas/db.py b/product/crud/integration/schemas/db.py index 1f0b5d3..4ede0ed 100644 --- a/product/crud/integration/schemas/db.py +++ b/product/crud/integration/schemas/db.py @@ -1,14 +1,8 @@ -from typing import Annotated, List +from typing import List -from pydantic import BaseModel, Field, PositiveInt +from pydantic import BaseModel -from product.crud.schemas.shared_types import ProductId - - -class Product(BaseModel): - name: Annotated[str, Field(min_length=1, max_length=30)] - id: ProductId # primary key - price: PositiveInt +from product.models.products.product import Product class ProductEntries(BaseModel): diff --git a/product/crud/schemas/input.py b/product/crud/schemas/input.py index bab46ae..c4d4cef 100644 --- a/product/crud/schemas/input.py +++ b/product/crud/schemas/input.py @@ -3,7 +3,7 @@ from aws_lambda_powertools.utilities.parser.models import APIGatewayProxyEventModel from pydantic import BaseModel, Field, Json, PositiveInt -from product.crud.schemas.shared_types import ProductId +from product.models.products.product import ProductId class CreateProductBody(BaseModel): diff --git a/product/crud/schemas/output.py b/product/crud/schemas/output.py index a38565c..52af21a 100644 --- a/product/crud/schemas/output.py +++ b/product/crud/schemas/output.py @@ -2,7 +2,7 @@ from pydantic import BaseModel, Field, PositiveInt -from product.crud.schemas.shared_types import ProductId +from product.models.products.product import ProductId class CreateProductOutput(BaseModel): diff --git a/product/crud/schemas/shared_types.py b/product/crud/schemas/shared_types.py deleted file mode 100644 index 36138ac..0000000 --- a/product/crud/schemas/shared_types.py +++ /dev/null @@ -1,16 +0,0 @@ -from typing import Annotated -from uuid import UUID - -from pydantic import Field -from pydantic.functional_validators import AfterValidator - - -def validate_uuid(v: str) -> str: - try: - UUID(v, version=4) - except Exception as exc: - raise ValueError(str(exc)) - return v - - -ProductId = Annotated[str, Field(min_length=36, max_length=36), AfterValidator(validate_uuid)] diff --git a/tests/e2e/crud/test_create_product.py b/tests/e2e/crud/test_create_product.py index a6d66b2..861fdda 100644 --- a/tests/e2e/crud/test_create_product.py +++ b/tests/e2e/crud/test_create_product.py @@ -3,7 +3,7 @@ import requests -from tests.crud_utils import generate_create_product_request_body +from tests.crud_utils import generate_create_product_request_body, generate_product_id def test_handler_200_ok(api_gw_url_slash_product: str, product_id: str): @@ -24,13 +24,52 @@ def test_handler_200_ok(api_gw_url_slash_product: str, product_id: str): # AND WHEN making the same PUT request again response = requests.put(url=url_with_product_id, data=body.model_dump_json(), timeout=10) + # THEN the response should indicate a 200 OK since idempotency is active within 1 minute range + # AND the response body should contain the exact product id + assert response.status_code == HTTPStatus.OK + body_dict = json.loads(response.text) + assert body_dict['id'] == product_id + + # AND WHEN making a PUT request again with same product id but different price and name + body = generate_create_product_request_body() + response = requests.put(url=url_with_product_id, data=body.model_dump_json(), timeout=10) + # THEN the response should indicate a bad request (HTTP 400) - # AND the response body should indicate the error due to product existence + # AND the response body should indicate the error due to product existence (with different params) assert response.status_code == HTTPStatus.BAD_REQUEST body_dict = json.loads(response.text) assert body_dict['error'] == 'product already exists' +def test_idempotency_does_not_affect_different_ids(api_gw_url_slash_product: str): + # GIVEN a URL and product ID for creating a product + # AND a valid request body for creating a product + body = generate_create_product_request_body() + expected_product_id = generate_product_id() + url_with_product_id = f'{api_gw_url_slash_product}/{expected_product_id}' + + # WHEN making a PUT request to create a product + response = requests.put(url=url_with_product_id, data=body.model_dump_json(), timeout=10) + + # THEN the response should indicate success (HTTP 200) + # AND the response body should contain the provided product ID + assert response.status_code == HTTPStatus.OK + body_dict = json.loads(response.text) + assert body_dict['id'] == expected_product_id + + # AND WHEN making a PUT request again with different product id + body = generate_create_product_request_body() + expected_product_id = generate_product_id() + url_with_product_id = f'{api_gw_url_slash_product}/{expected_product_id}' + response = requests.put(url=url_with_product_id, data=body.model_dump_json(), timeout=10) + + # THEN the response should indicate success (HTTP 200) + # AND the response body should contain the new product ID + assert response.status_code == HTTPStatus.OK + body_dict = json.loads(response.text) + assert body_dict['id'] == expected_product_id + + def test_handler_bad_request_invalid_body(api_gw_url_slash_product: str, product_id: str): # GIVEN a URL and product ID for creating a product # AND an invalid request body missing the "name" parameter diff --git a/tests/integration/crud/test_create_product.py b/tests/integration/crud/test_create_product.py index 1842c87..b6f1c28 100644 --- a/tests/integration/crud/test_create_product.py +++ b/tests/integration/crud/test_create_product.py @@ -4,20 +4,26 @@ import boto3 from botocore.stub import Stubber -from product.crud.handlers.handle_create_product import lambda_handler from product.crud.integration.dynamo_db_handler import DynamoDbHandler from product.crud.integration.schemas.db import Product from tests.crud_utils import generate_create_product_request_body, generate_product_api_gw_event, generate_product_id from tests.utils import generate_context -def test_handler_200_ok(table_name: str): +def call_handler(event, context): + from product.crud.handlers.handle_create_product import lambda_handler + return lambda_handler(event, context) + + +def test_handler_200_ok(monkeypatch, table_name: str): + # Set POWERTOOLS_IDEMPOTENCY_DISABLED before calling decorated functions + monkeypatch.setenv('POWERTOOLS_IDEMPOTENCY_DISABLED', 1) # GIVEN a product creation request body = generate_create_product_request_body() product_id = generate_product_id() # WHEN the lambda handler processes the request - response = lambda_handler( + response = call_handler( event=generate_product_api_gw_event(http_method=HTTPMethod.PUT, product_id=product_id, body=body.model_dump(), path_params={'product': product_id}), context=generate_context(), @@ -42,7 +48,7 @@ def test_handler_bad_request_product_already_exists(add_product_entry_to_db: Pro product_id = add_product_entry_to_db.id # WHEN attempting to create a product with the same ID - response = lambda_handler( + response = call_handler( event=generate_product_api_gw_event(http_method=HTTPMethod.PUT, product_id=product_id, body=add_product_entry_to_db.model_dump(), path_params={'product': product_id}), context=generate_context(), @@ -66,7 +72,7 @@ def test_internal_server_error(table_name: str): product_id = generate_product_id() # WHEN attempting to create a product while the DynamoDB exception is triggered - response = lambda_handler( + response = call_handler( event=generate_product_api_gw_event(http_method=HTTPMethod.PUT, product_id=product_id, body=body.model_dump(), path_params={'product': product_id}), context=generate_context(), @@ -84,7 +90,7 @@ def test_handler_bad_request_invalid_body_input(): product_id = generate_product_id() # WHEN the lambda handler processes the request - response = lambda_handler( + response = call_handler( event=generate_product_api_gw_event(http_method=HTTPMethod.PUT, product_id=product_id, body={'price': 5}, path_params={'product': product_id}), context=generate_context(), @@ -102,7 +108,7 @@ def test_handler_bad_request_invalid_product_id(): product_id = 'aaaaaa' body = generate_create_product_request_body() # WHEN the lambda handler processes the request - response = lambda_handler( + response = call_handler( event=generate_product_api_gw_event(http_method=HTTPMethod.PUT, product_id=product_id, body=body.model_dump(), path_params={'product': product_id}), context=generate_context(), @@ -121,7 +127,7 @@ def test_handler_bad_request_invalid_path_params(): product_id = generate_product_id() # WHEN the lambda handler processes the request - response = lambda_handler( + response = call_handler( event=generate_product_api_gw_event(http_method=HTTPMethod.PUT, product_id=product_id, body=body.model_dump(), path_params={'dummy': product_id}, path='dummy'), context=generate_context(), diff --git a/tests/unit/crud/test_product_id.py b/tests/unit/crud/test_product_id.py index 86da8bc..5b70282 100644 --- a/tests/unit/crud/test_product_id.py +++ b/tests/unit/crud/test_product_id.py @@ -1,6 +1,6 @@ import pytest -from product.crud.schemas.shared_types import validate_uuid +from product.models.products.validators import validate_product_id # Invalid ids @@ -16,14 +16,14 @@ ]) def test_invalid_id(invalid_id): # GIVEN an invalid id - # WHEN attempting to validate it using validate_uuid + # WHEN attempting to validate it using validate_product_id # THEN a ValueError should be raised with pytest.raises(ValueError): - validate_uuid(invalid_id) + validate_product_id(invalid_id) def test_valid_id(product_id): # GIVEN a valid id - # WHEN attempting to validate it using validate_uuid + # WHEN attempting to validate it using validate_product_id # THEN it should be validated without errors - validate_uuid(product_id) + validate_product_id(product_id)