Skip to content

Commit

Permalink
feature: add idempotency to create product API (#88)
Browse files Browse the repository at this point in the history
Co-authored-by: Ran Isenberg <ran.isenberg@ranthebuilder.cloud>
  • Loading branch information
ran-isenberg and Ran Isenberg authored Oct 9, 2023
1 parent ac58a55 commit 7b1d8c9
Show file tree
Hide file tree
Showing 14 changed files with 90 additions and 71 deletions.
21 changes: 18 additions & 3 deletions product/crud/domain_logic/create_product.py
Original file line number Diff line number Diff line change
@@ -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')
Expand Down
9 changes: 6 additions & 3 deletions product/crud/handlers/handle_create_product.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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,
)

Expand Down
2 changes: 1 addition & 1 deletion product/crud/handlers/handle_delete_product.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
1 change: 0 additions & 1 deletion product/crud/handlers/handle_get_product.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
from http import HTTPMethod
from typing import Any

from aws_lambda_env_modeler import get_environment_variables, init_environment_variables
Expand Down
1 change: 0 additions & 1 deletion product/crud/handlers/handle_list_products.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
from http import HTTPMethod
from typing import Any

from aws_lambda_env_modeler import get_environment_variables, init_environment_variables
Expand Down
10 changes: 0 additions & 10 deletions product/crud/integration/dynamo_db_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Expand All @@ -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')
Expand Down
10 changes: 0 additions & 10 deletions product/crud/integration/idempotency.py

This file was deleted.

12 changes: 3 additions & 9 deletions product/crud/integration/schemas/db.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down
2 changes: 1 addition & 1 deletion product/crud/schemas/input.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion product/crud/schemas/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
16 changes: 0 additions & 16 deletions product/crud/schemas/shared_types.py

This file was deleted.

43 changes: 41 additions & 2 deletions tests/e2e/crud/test_create_product.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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
Expand Down
22 changes: 14 additions & 8 deletions tests/integration/crud/test_create_product.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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(),
Expand All @@ -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(),
Expand All @@ -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(),
Expand All @@ -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(),
Expand All @@ -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(),
Expand Down
10 changes: 5 additions & 5 deletions tests/unit/crud/test_product_id.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)

0 comments on commit 7b1d8c9

Please sign in to comment.