Skip to content

Add a Delete order lambda #940

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

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
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
43 changes: 41 additions & 2 deletions cdk/service/api_construct.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,11 @@ def __init__(self, scope: Construct, id_: str, appconfig_app_name: str, is_produ
self.create_order_func = self._add_post_lambda_integration(
orders_resource, self.lambda_role, self.api_db.db, appconfig_app_name, self.api_db.idempotency_db
)
self.delete_order_func = self._add_delete_lambda_integration(
orders_resource, self.lambda_role, self.api_db.db, appconfig_app_name, self.api_db.idempotency_db
)
self._build_swagger_endpoints(rest_api=self.rest_api, dest_func=self.create_order_func)
self.monitoring = CrudMonitoring(self, id_, self.rest_api, self.api_db.db, self.api_db.idempotency_db, [self.create_order_func])
self.monitoring = CrudMonitoring(self, id_, self.rest_api, self.api_db.db, self.api_db.idempotency_db, [self.create_order_func, self.delete_order_func])

if is_production_env:
# add WAF
Expand Down Expand Up @@ -76,7 +79,7 @@ def _build_lambda_role(self, db: dynamodb.TableV2, idempotency_table: dynamodb.T
'dynamodb_db': iam.PolicyDocument(
statements=[
iam.PolicyStatement(
actions=['dynamodb:PutItem'],
actions=['dynamodb:PutItem', 'dynamodb:DeleteItem'],
resources=[db.table_arn],
effect=iam.Effect.ALLOW,
)
Expand Down Expand Up @@ -146,3 +149,39 @@ def _add_post_lambda_integration(
# POST /api/orders/
api_resource.add_method(http_method='POST', integration=aws_apigateway.LambdaIntegration(handler=lambda_function))
return lambda_function

def _add_delete_lambda_integration(
self,
api_resource: aws_apigateway.Resource,
role: iam.Role,
db: dynamodb.TableV2,
appconfig_app_name: str,
idempotency_table: dynamodb.TableV2,
) -> _lambda.Function:
lambda_function = _lambda.Function(
self,
constants.DELETE_LAMBDA,
runtime=_lambda.Runtime.PYTHON_3_13,
code=_lambda.Code.from_asset(constants.BUILD_FOLDER),
handler='service.handlers.handle_delete_order.lambda_handler',
environment={
constants.POWERTOOLS_SERVICE_NAME: constants.SERVICE_NAME, # for logger, tracer and metrics
constants.POWER_TOOLS_LOG_LEVEL: 'INFO', # for logger
'TABLE_NAME': db.table_name,
'IDEMPOTENCY_TABLE_NAME': idempotency_table.table_name,
},
tracing=_lambda.Tracing.ACTIVE,
retry_attempts=0,
timeout=Duration.seconds(constants.API_HANDLER_LAMBDA_TIMEOUT),
memory_size=constants.API_HANDLER_LAMBDA_MEMORY_SIZE,
layers=[self.common_layer],
role=role,
log_retention=RetentionDays.ONE_DAY,
logging_format=_lambda.LoggingFormat.JSON,
system_log_level_v2=_lambda.SystemLogLevel.INFO,
)

# DELETE /api/orders/{order_id}
order_resource = api_resource.add_resource('{order_id}')
order_resource.add_method(http_method='DELETE', integration=aws_apigateway.LambdaIntegration(handler=lambda_function))
return lambda_function
1 change: 1 addition & 0 deletions cdk/service/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
LAMBDA_BASIC_EXECUTION_ROLE = 'AWSLambdaBasicExecutionRole'
SERVICE_ROLE = 'ServiceRole'
CREATE_LAMBDA = 'CreateOrder'
DELETE_LAMBDA = 'DeleteOrder'
TABLE_NAME = 'orders'
IDEMPOTENCY_TABLE_NAME = 'Idempotency'
TABLE_NAME_OUTPUT = 'DbOutput'
Expand Down
3 changes: 3 additions & 0 deletions service/dal/db_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,6 @@ def __call__(cls, *args, **kwargs):
class DalHandler(ABC, metaclass=_SingletonMeta):
@abstractmethod
def create_order_in_db(self, customer_name: str, order_item_count: int) -> Order: ... # pragma: no cover

@abstractmethod
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add cdk code that generates all the required resources, make sure to add at the correct place

def delete_order_in_db(self, order_id: str) -> Order: ... # pragma: no cover
29 changes: 29 additions & 0 deletions service/dal/dynamo_dal_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,32 @@ def create_order_in_db(self, customer_name: str, order_item_count: int) -> Order

logger.info('finished create order successfully', order_item_count=order_item_count, customer_name=customer_name)
return Order(id=entry.id, name=entry.name, item_count=entry.item_count)

@tracer.capture_method(capture_response=False)
def delete_order_in_db(self, order_id: str) -> Order:
logger.append_keys(order_id=order_id)
logger.info('trying to delete order', order_id=order_id)
try:
table: Table = self._get_db_handler(self.table_name)
response = table.delete_item(
Key={'id': order_id},
ReturnValues='ALL_OLD'
)

if 'Attributes' not in response:
error_msg = f'Order with ID {order_id} not found'
logger.error(error_msg)
raise InternalServerException(error_msg)

attributes = response['Attributes']
deleted_order = Order(
id=attributes['id'],
name=attributes['name'],
item_count=attributes['item_count']
)
logger.info('finished delete order successfully', order_id=order_id)
return deleted_order
except (ClientError, ValidationError) as exc: # pragma: no cover
error_msg = 'failed to delete order'
logger.exception(error_msg, order_id=order_id)
raise InternalServerException(error_msg) from exc
58 changes: 58 additions & 0 deletions service/handlers/handle_delete_order.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
from typing import Annotated, Any

from aws_lambda_env_modeler import get_environment_variables, init_environment_variables
from aws_lambda_powertools.event_handler.openapi.params import Path
from aws_lambda_powertools.logging import correlation_paths
from aws_lambda_powertools.metrics import MetricUnit
from aws_lambda_powertools.utilities.typing import LambdaContext

from service.handlers.models.env_vars import MyHandlerEnvVars
from service.handlers.utils.observability import logger, metrics, tracer
from service.handlers.utils.rest_api_resolver import ORDERS_PATH, app
from service.logic.delete_order import delete_order
from service.models.input import DeleteOrderRequest
from service.models.output import DeleteOrderOutput, InternalServerErrorOutput


@app.delete(
f"{ORDERS_PATH}{{order_id}}",
summary='Delete an order',
description='Delete an order identified by the order_id',
response_description='The deleted order',
responses={
200: {
'description': 'The deleted order',
'content': {'application/json': {'model': DeleteOrderOutput}},
},
501: {
'description': 'Internal server error',
'content': {'application/json': {'model': InternalServerErrorOutput}},
},
},
tags=['CRUD'],
)
def handle_delete_order(order_id: Annotated[str, Path()]) -> DeleteOrderOutput:
env_vars: MyHandlerEnvVars = get_environment_variables(model=MyHandlerEnvVars)
logger.debug('environment variables', env_vars=env_vars.model_dump())
logger.info('got delete order request', order_id=order_id)

# Create request model
delete_input = DeleteOrderRequest(order_id=order_id)

metrics.add_metric(name='ValidDeleteOrderEvents', unit=MetricUnit.Count, value=1)
response: DeleteOrderOutput = delete_order(
order_request=delete_input,
table_name=env_vars.TABLE_NAME,
context=app.lambda_context,
)

logger.info('finished handling delete order request')
return response


@init_environment_variables(model=MyHandlerEnvVars)
@logger.inject_lambda_context(correlation_id_path=correlation_paths.API_GATEWAY_REST)
@metrics.log_metrics
@tracer.capture_lambda_handler(capture_response=False)
def lambda_handler(event: dict[str, Any], context: LambdaContext) -> dict[str, Any]:
return app.resolve(event, context)
54 changes: 54 additions & 0 deletions service/handlers/test_handle_delete_order.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import json
from unittest.mock import MagicMock, patch

import pytest
from aws_lambda_powertools.event_handler.openapi.params import Path

from service.handlers.handle_delete_order import handle_delete_order, lambda_handler
from service.models.input import DeleteOrderRequest
from service.models.output import DeleteOrderOutput


@patch('service.handlers.handle_delete_order.delete_order')
@patch('service.handlers.handle_delete_order.app')
@patch('service.handlers.handle_delete_order.get_environment_variables')
@patch('service.handlers.handle_delete_order.parse_configuration')
def test_handle_delete_order_success(mock_parse_config, mock_get_env_vars, mock_app, mock_delete_order):
# Setup
order_id = "12345678-1234-1234-1234-123456789012"
mock_env_vars = MagicMock()
mock_env_vars.TABLE_NAME = "test-table"
mock_get_env_vars.return_value = mock_env_vars

# Set up the expected return value from delete_order
expected_output = DeleteOrderOutput(id=order_id, name="Test Customer", item_count=5)
mock_delete_order.return_value = expected_output

# Execute
result = handle_delete_order(order_id=order_id)

# Verify
assert result == expected_output

# Check that delete_order was called with correct parameters
mock_delete_order.assert_called_once()
called_args = mock_delete_order.call_args.kwargs
assert isinstance(called_args['delete_request'], DeleteOrderRequest)
assert called_args['delete_request'].order_id == order_id
assert called_args['table_name'] == mock_env_vars.TABLE_NAME
assert called_args['context'] == mock_app.lambda_context


@patch('service.handlers.handle_delete_order.app')
def test_lambda_handler(mock_app):
# Setup
event = {'some': 'event'}
context = {'some': 'context'}
mock_app.resolve.return_value = {'some': 'response'}

# Execute
response = lambda_handler(event=event, context=context)

# Verify
mock_app.resolve.assert_called_once_with(event, context)
assert response == {'some': 'response'}
35 changes: 35 additions & 0 deletions service/logic/delete_order.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from aws_lambda_powertools.utilities.idempotency import idempotent_function
from aws_lambda_powertools.utilities.idempotency.serialization.pydantic import PydanticSerializer
from aws_lambda_powertools.utilities.typing import LambdaContext

from service.dal import get_dal_handler
from service.dal.db_handler import DalHandler
from service.handlers.utils.observability import logger, tracer
from service.logic.utils.idempotency import IDEMPOTENCY_CONFIG, IDEMPOTENCY_LAYER
from service.models.input import DeleteOrderRequest
from service.models.order import Order
from service.models.output import DeleteOrderOutput


@idempotent_function(
data_keyword_argument='order_request',
config=IDEMPOTENCY_CONFIG,
persistence_store=IDEMPOTENCY_LAYER,
output_serializer=PydanticSerializer,
)
@tracer.capture_method(capture_response=False)
def delete_order(order_request: DeleteOrderRequest, table_name: str, context: LambdaContext) -> DeleteOrderOutput:
IDEMPOTENCY_CONFIG.register_lambda_context(context) # see Lambda timeouts section

logger.info('starting to handle delete request', order_id=order_request.order_id)

# get the data access layer handler
dal_handler: DalHandler = get_dal_handler(table_name=table_name)

# delete order in database
order: Order = dal_handler.delete_order_in_db(order_id=order_request.order_id)

# create response
response = DeleteOrderOutput(**order.model_dump())
logger.info('successfully handled delete order request')
return response
75 changes: 75 additions & 0 deletions service/logic/test_delete_order.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import uuid
from unittest.mock import MagicMock, patch

import pytest
from aws_lambda_powertools.utilities.idempotency import IdempotencyConfig, idempotency_function
from aws_lambda_powertools.utilities.typing import LambdaContext

from service.dal.db_handler import DalHandler
from service.logic.delete_order import delete_order
from service.models.input import DeleteOrderRequest
from service.models.order import Order
from service.models.output import DeleteOrderOutput


@pytest.fixture
def lambda_context():
return MagicMock(spec=LambdaContext)


@pytest.fixture
def mock_dal_handler():
dal_handler = MagicMock(spec=DalHandler)
return dal_handler


@pytest.fixture
def delete_request():
return DeleteOrderRequest(order_id="12345678-1234-1234-1234-123456789012")


@patch('service.logic.delete_order.idempotent_function')
@patch('service.logic.delete_order.get_dal_handler')
def test_successful_delete_order(mock_get_dal_handler, mock_idempotent, mock_dal_handler, delete_request, lambda_context):
# Setup
# Mock idempotent_function decorator to call the function directly
mock_idempotent.side_effect = lambda *args, **kwargs: kwargs.get('data_keyword_argument') and idempotency_function(**kwargs) or args[0]

# Mock the DAL handler to return an order
mock_order = Order(id=delete_request.order_id, name="Test Customer", item_count=5)
mock_dal_handler.delete_order.return_value = mock_order
mock_get_dal_handler.return_value = mock_dal_handler

# Execute
result = delete_order(delete_request=delete_request, table_name="test-table", context=lambda_context)

# Verify
assert result.id == mock_order.id
assert result.name == mock_order.name
assert result.item_count == mock_order.item_count

# Check that the DAL handler was called with the correct order ID
mock_dal_handler.delete_order.assert_called_once_with(order_id=delete_request.order_id)


@patch('service.logic.delete_order.idempotent_function')
@patch('service.logic.delete_order.get_dal_handler')
def test_delete_order_not_found(mock_get_dal_handler, mock_idempotent, mock_dal_handler, delete_request, lambda_context):
# Setup
# Mock idempotent_function decorator to call the function directly
mock_idempotent.side_effect = lambda *args, **kwargs: kwargs.get('data_keyword_argument') and idempotency_function(**kwargs) or args[0]

# Mock the DAL handler to return None, indicating no order was found
mock_dal_handler.delete_order.return_value = None
mock_get_dal_handler.return_value = mock_dal_handler

# Execute
result = delete_order(delete_request=delete_request, table_name="test-table", context=lambda_context)

# Verify
assert result.id == delete_request.order_id
assert result.name == "Order not found"
assert result.item_count == 0

# Check that the DAL handler was called with the correct order ID
mock_dal_handler.delete_order.assert_called_once_with(order_id=delete_request.order_id)
7 changes: 7 additions & 0 deletions service/models/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
class InternalServerException(Exception):
"""Raised when an unexpected error occurs in the server"""
pass


class OrderNotFoundException(Exception):
"""Raised when trying to access an order that doesn't exist"""
pass


class DynamicConfigurationException(Exception):
"""Raised when AppConfig fails to return configuration data"""
pass
6 changes: 6 additions & 0 deletions service/models/input.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from pydantic import BaseModel, Field, field_validator

from service.models.order import OrderId


class CreateOrderRequest(BaseModel):
customer_name: Annotated[str, Field(min_length=1, max_length=20, description='Customer name')]
Expand All @@ -15,3 +17,7 @@ def check_order_item_count(cls, v):
if v <= 0:
raise ValueError('order_item_count must be larger than 0')
return v


class DeleteOrderRequest(BaseModel):
order_id: OrderId
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add unit test for pydantic schema

4 changes: 4 additions & 0 deletions service/models/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,9 @@ class CreateOrderOutput(Order):
pass


class DeleteOrderOutput(Order):
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add unit test for pydantic schema

"""Output payload for delete order operation."""


class InternalServerErrorOutput(BaseModel):
error: Annotated[str, Field(description='Error description')] = 'internal server error'
Loading
Loading