Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature: add idempotency to create product API #88

Merged
merged 2 commits into from
Oct 9, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
Loading