Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
5 changes: 4 additions & 1 deletion service/dal/db_handler.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from abc import ABC, ABCMeta, abstractmethod

from service.models.order import Order
from service.models.order import Order, OrderId


class _SingletonMeta(ABCMeta):
Expand All @@ -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: OrderId) -> Order: ... # pragma: no cover
30 changes: 28 additions & 2 deletions service/dal/dynamo_dal_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
from service.dal.db_handler import DalHandler
from service.dal.models.db import OrderEntry
from service.handlers.utils.observability import logger, tracer
from service.models.exceptions import InternalServerException
from service.models.order import Order
from service.models.exceptions import InternalServerException, OrderNotFoundException
from service.models.order import Order, OrderId


class DynamoDalHandler(DalHandler):
Expand Down Expand Up @@ -50,3 +50,29 @@ 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: OrderId) -> Order:
logger.append_keys(order_id=order_id)
logger.info('trying to delete order')

try:
table: Table = self._get_db_handler(self.table_name)
response = table.get_item(Key={'id': order_id})

if 'Item' not in response:
error_msg = f'Order with id {order_id} not found'
logger.error(error_msg)
raise OrderNotFoundException(error_msg)

order_item = response['Item']
order = Order(id=order_item['id'], name=order_item['name'], item_count=order_item['item_count'])

table.delete_item(Key={'id': order_id})
except ClientError as exc:
error_msg = 'failed to delete order'
logger.exception(error_msg)
raise InternalServerException(error_msg) from exc

logger.info('finished delete order successfully')
return order
70 changes: 70 additions & 0 deletions service/handlers/handle_delete_order.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
from http import HTTPStatus
from typing import Annotated, Any

from aws_lambda_env_modeler import get_environment_variables, init_environment_variables
from aws_lambda_powertools.event_handler import Response, content_types
from aws_lambda_powertools.event_handler.openapi.params import Body
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.exceptions import OrderNotFoundException
from service.models.input import DeleteOrderRequest
from service.models.output import DeleteOrderOutput, InternalServerErrorOutput, OrderNotFoundOutput


@app.post(
f"{ORDERS_PATH}delete",
summary='Delete an order',
description='Delete an order identified by the provided order ID',
response_description='The deleted order details',
responses={
200: {
'description': 'The deleted order',
'content': {'application/json': {'model': DeleteOrderOutput}},
},
404: {
'description': 'Order not found',
'content': {'application/json': {'model': OrderNotFoundOutput}},
},
501: {
'description': 'Internal server error',
'content': {'application/json': {'model': InternalServerErrorOutput}},
},
},
tags=['CRUD'],
)
def handle_delete_order(delete_input: Annotated[DeleteOrderRequest, Body(embed=False, media_type='application/json')]) -> 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=delete_input.order_id)

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

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


@app.exception_handler(OrderNotFoundException)
def handle_order_not_found_error(ex: OrderNotFoundException):
logger.exception('order not found')
return Response(
status_code=HTTPStatus.NOT_FOUND, content_type=content_types.APPLICATION_JSON, body=OrderNotFoundOutput().model_dump()
)


@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)
34 changes: 34 additions & 0 deletions service/logic/delete_order.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
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.exceptions import OrderNotFoundException
from service.models.input import DeleteOrderRequest
from service.models.order import Order
from service.models.output import DeleteOrderOutput


@idempotent_function(
data_keyword_argument='delete_request',
config=IDEMPOTENCY_CONFIG,
persistence_store=IDEMPOTENCY_LAYER,
output_serializer=PydanticSerializer,
)
@tracer.capture_method(capture_response=False)
def delete_order(delete_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=delete_request.order_id)

dal_handler: DalHandler = get_dal_handler(table_name)
try:
order: Order = dal_handler.delete_order_in_db(delete_request.order_id)
# convert from order object to output, they won't always be the same
return DeleteOrderOutput(name=order.name, item_count=order.item_count, id=order.id)
except OrderNotFoundException as exc:
logger.exception('order not found', order_id=delete_request.order_id)
raise exc
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

8 changes: 8 additions & 0 deletions service/models/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,11 @@ class CreateOrderOutput(Order):

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


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

pass


class OrderNotFoundOutput(BaseModel):
error: Annotated[str, Field(description='Error description')] = 'order not found'
94 changes: 94 additions & 0 deletions service/tests/e2e/test_delete_order.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import json
import uuid
from typing import Dict

import pytest
import requests

from service.models.order import Order


@pytest.fixture(scope='module')
def api_gw_url():
import os
url = os.environ.get('ORDER_API_GW_URL')
if not url:
raise ValueError('Missing environment variable: ORDER_API_GW_URL')
return url


def test_delete_order_flow(api_gw_url):
# First create an order to delete
customer_name = 'E2E Test Customer'
order_item_count = 3

# Create order
create_url = f"{api_gw_url}/api/orders/"
create_response = requests.post(
create_url,
json={
'customer_name': customer_name,
'order_item_count': order_item_count
}
)

assert create_response.status_code == 200
created_order = create_response.json()
order_id = created_order['id']

# Delete the order
delete_url = f"{api_gw_url}/api/orders/delete"
delete_response = requests.post(
delete_url,
json={
'order_id': order_id
}
)

# Check the response
assert delete_response.status_code == 200
deleted_order = delete_response.json()
assert deleted_order['id'] == order_id
assert deleted_order['name'] == customer_name
assert deleted_order['item_count'] == order_item_count

# Try to delete the same order again, should get a 404
delete_again_response = requests.post(
delete_url,
json={
'order_id': order_id
}
)

assert delete_again_response.status_code == 404
assert delete_again_response.json()['error'] == 'order not found'


def test_delete_nonexistent_order(api_gw_url):
delete_url = f"{api_gw_url}/api/orders/delete"
nonexistent_order_id = str(uuid.uuid4())

response = requests.post(
delete_url,
json={
'order_id': nonexistent_order_id
}
)

assert response.status_code == 404
assert response.json()['error'] == 'order not found'


def test_delete_invalid_order_id(api_gw_url):
delete_url = f"{api_gw_url}/api/orders/delete"

# Test with an invalid UUID
response = requests.post(
delete_url,
json={
'order_id': 'not-a-uuid'
}
)

# Should get a validation error
assert response.status_code == 422
89 changes: 89 additions & 0 deletions service/tests/integration/test_delete_order.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import json
import uuid
from typing import Any, Dict
from unittest.mock import patch

import boto3
import pytest
from botocore.stub import Stubber
from mypy_boto3_dynamodb.service_resource import Table

from service.dal import DynamoDalHandler
from service.handlers.handle_delete_order import lambda_handler
from service.models.exceptions import OrderNotFoundException, InternalServerException


def call_delete_order(body: Dict[str, Any]) -> Dict[str, Any]:
event = {
'body': json.dumps(body),
'httpMethod': 'POST',
'path': '/api/orders/delete',
'requestContext': {'requestId': '227b78aa-779d-47d4-a48a-e83c31501c64'},
}
response = lambda_handler(event=event, context=None)
return response


def test_handler_200_ok(mocker, table_name: str):
# Create a real order in DynamoDB
order_id = str(uuid.uuid4())
customer_name = "Integration Test Customer"
item_count = 10

# Create the item to be used in tests
table: Table = boto3.resource('dynamodb').Table(table_name)
table.put_item(
Item={
'id': order_id,
'name': customer_name,
'item_count': item_count,
'created_at': 1234567890,
}
)

# Call delete order handler
result = call_delete_order({'order_id': order_id})

# Verify the response
assert result['statusCode'] == 200
assert json.loads(result['body'])['id'] == order_id
assert json.loads(result['body'])['name'] == customer_name
assert json.loads(result['body'])['item_count'] == item_count

# Verify the item was actually deleted
response = table.get_item(Key={'id': order_id})
assert 'Item' not in response


def test_handler_404_not_found(mocker, table_name: str):
# Use a UUID that doesn't exist
order_id = str(uuid.uuid4())

# Call delete order handler for non-existent order
result = call_delete_order({'order_id': order_id})

# Verify the response
assert result['statusCode'] == 404
assert json.loads(result['body'])['error'] == 'order not found'


def test_internal_server_error(mocker, table_name: str):
# Mock DynamoDB client to simulate a ClientError
order_id = str(uuid.uuid4())

# Get the table
table: Table = boto3.resource('dynamodb').Table(table_name)

with Stubber(table.meta.client) as stubber:
# Stub the get_item method to raise an exception
stubber.add_client_error('get_item', service_error_code='InternalServerError')

# Mock get_db_handler to return our stubbed table
mocker.patch.object(DynamoDalHandler, '_get_db_handler', return_value=table)

# Call delete order with the stubbed client
result = call_delete_order({'order_id': order_id})

# Verify the response
assert result['statusCode'] == 501
assert json.loads(result['body'])['error'] == 'internal server error'
Loading
Loading