Skip to content

Commit 096e999

Browse files
Lucas BeloLucas Belo
Lucas Belo
authored and
Lucas Belo
committed
DynamoDB
1 parent d8d1df0 commit 096e999

File tree

7 files changed

+767
-3
lines changed

7 files changed

+767
-3
lines changed

services/tasks_api/models.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,4 @@ class Task:
1717

1818
@classmethod
1919
def create(cls, id_, title, owner):
20-
return cls(id_, title, TaskStatus.OPEN, owner)
20+
return cls(id_, title, TaskStatus.OPEN, owner)

services/tasks_api/poetry.lock

+558-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

services/tasks_api/pyproject.toml

+2
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ fastapi = "^0.115.0"
1111
uvicorn = "^0.31.0"
1212
httpx = "^0.27.2"
1313
mangum = "^0.19.0"
14+
boto3 = "1.21.45"
1415

1516

1617
[tool.poetry.group.dev.dependencies]
@@ -20,6 +21,7 @@ black = "^24.8.0"
2021
isort = "^5.13.2"
2122
flake8 = "^7.1.1"
2223
bandit = "^1.7.10"
24+
moto = "3.1.5"
2325

2426
[build-system]
2527
requires = ["poetry-core"]
+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
Resources:
2+
TasksAPITable:
3+
Type: AWS::DynamoDB::Table
4+
Properties:
5+
TableName: ${self:custom.tableName}
6+
BillingMode: PAY_PER_REQUEST
7+
AttributeDefinitions:
8+
- AttributeName: PK
9+
AttributeType: S
10+
- AttributeName: SK
11+
AttributeType: S
12+
- AttributeName: GS1PK
13+
AttributeType: S
14+
- AttributeName: GS1SK
15+
AttributeType: S
16+
KeySchema:
17+
- AttributeName: PK
18+
KeyType: HASH
19+
- AttributeName: SK
20+
KeyType: RANGE
21+
GlobalSecondaryIndexes:
22+
- IndexName: GS1
23+
KeySchema:
24+
- AttributeName: GS1PK
25+
KeyType: HASH
26+
- AttributeName: GS1SK
27+
KeyType: RANGE
28+
Projection:
29+
ProjectionType: ALL

services/tasks_api/serverless.yml

+23-1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,23 @@ provider:
1212
logRetentionInDays: 30
1313
environment:
1414
APP_ENVIRONMENT: ${self:provider.stage}
15+
iam:
16+
role:
17+
statements:
18+
- Effect: Allow
19+
Action:
20+
- dynamodb:DescribeTable
21+
- dynamodb:Query
22+
- dynamodb:Scan
23+
- dynamodb:GetItem
24+
- dynamodb:PutItem
25+
- dynamodb:UpdateItem
26+
- dynamodb:DeleteItem
27+
# Allow only access to the API's table and its indexes
28+
Resource:
29+
- "Fn::GetAtt": [ TasksAPITable, Arn ]
30+
- "Fn::Join": ['/', ["Fn::GetAtt": [ TasksAPITable, Arn ], 'index', '*']]
31+
1532

1633
functions:
1734
API:
@@ -33,6 +50,11 @@ custom:
3350
noDeploy:
3451
- boto3 # already on Lambda
3552
- botocore # already on Lambda
53+
stage: ${opt:stage, self:provider.stage}
54+
tableName: ${self:custom.stage}-tasks-api
3655

3756
plugins:
38-
- serverless-python-requirements
57+
- serverless-python-requirements
58+
59+
resources:
60+
- ${file(resources/dynamodb.yml)}

services/tasks_api/store.py

+79
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import datetime
2+
from uuid import UUID
3+
4+
import boto3
5+
from boto3.dynamodb.conditions import Key
6+
7+
from models import Task, TaskStatus
8+
9+
10+
class TaskStore:
11+
def __init__(self, table_name):
12+
self.table_name = table_name
13+
14+
def add(self, task):
15+
dynamodb = boto3.resource("dynamodb")
16+
table = dynamodb.Table(self.table_name)
17+
table.put_item(
18+
Item={
19+
"PK": f"#{task.owner}",
20+
"SK": f"#{task.id}",
21+
"GS1PK": f"#{task.owner}#{task.status.value}",
22+
"GS1SK": f"#{datetime.datetime.utcnow().isoformat()}",
23+
"id": str(task.id),
24+
"title": task.title,
25+
"status": task.status.value,
26+
"owner": task.owner,
27+
}
28+
)
29+
30+
def get_by_id(self, task_id, owner):
31+
dynamodb = boto3.resource("dynamodb")
32+
table = dynamodb.Table(self.table_name)
33+
record = table.get_item(
34+
Key={
35+
"PK": f"#{owner}",
36+
"SK": f"#{task_id}",
37+
},
38+
)
39+
return Task(
40+
id=UUID(record["Item"]["id"]),
41+
title=record["Item"]["title"],
42+
owner=record["Item"]["owner"],
43+
status=TaskStatus[record["Item"]["status"]],
44+
)
45+
46+
def list_open(self, owner):
47+
return self._list_by_status(owner, TaskStatus.OPEN)
48+
49+
def list_closed(self, owner):
50+
return self._list_by_status(owner, TaskStatus.CLOSED)
51+
52+
def _list_by_status(self, owner, status):
53+
dynamodb = boto3.resource("dynamodb")
54+
table = dynamodb.Table(self.table_name)
55+
last_key = None
56+
query_kwargs = {
57+
"IndexName": "GS1",
58+
"KeyConditionExpression": Key("GS1PK").eq(f"#{owner}#{status.value}"),
59+
}
60+
tasks = []
61+
while True:
62+
if last_key is not None:
63+
query_kwargs["ExclusiveStartKey"] = last_key
64+
response = table.query(**query_kwargs)
65+
tasks.extend(
66+
[
67+
Task(
68+
id=UUID(record["id"]),
69+
title=record["title"],
70+
owner=record["owner"],
71+
status=TaskStatus[record["status"]],
72+
)
73+
for record in response["Items"]
74+
]
75+
)
76+
last_key = response.get("LastEvaluatedKey")
77+
if last_key is None:
78+
break
79+
return tasks

services/tasks_api/tests.py

+75
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
1+
import uuid
2+
3+
import boto3
14
import pytest
25
from fastapi import status
6+
from moto import mock_dynamodb
37
from starlette.testclient import TestClient
48

59
from main import app
10+
from models import Task, TaskStatus
11+
from store import TaskStore
612

713

814
@pytest.fixture
@@ -19,3 +25,72 @@ def test_health_check(client):
1925
response = client.get("/api/health-check/")
2026
assert response.status_code == status.HTTP_200_OK
2127
assert response.json() == {"message": "OK"}
28+
29+
30+
@pytest.fixture
31+
def dynamodb_table():
32+
with mock_dynamodb():
33+
client = boto3.client("dynamodb")
34+
table_name = "test-table"
35+
client.create_table(
36+
AttributeDefinitions=[
37+
{"AttributeName": "PK", "AttributeType": "S"},
38+
{"AttributeName": "SK", "AttributeType": "S"},
39+
{"AttributeName": "GS1PK", "AttributeType": "S"},
40+
{"AttributeName": "GS1SK", "AttributeType": "S"},
41+
],
42+
TableName=table_name,
43+
KeySchema=[
44+
{"AttributeName": "PK", "KeyType": "HASH"},
45+
{"AttributeName": "SK", "KeyType": "RANGE"},
46+
],
47+
BillingMode="PAY_PER_REQUEST",
48+
GlobalSecondaryIndexes=[
49+
{
50+
"IndexName": "GS1",
51+
"KeySchema": [
52+
{"AttributeName": "GS1PK", "KeyType": "HASH"},
53+
{"AttributeName": "GS1SK", "KeyType": "RANGE"},
54+
],
55+
"Projection": {
56+
"ProjectionType": "ALL",
57+
},
58+
},
59+
],
60+
)
61+
yield table_name
62+
63+
64+
def test_added_task_retrieved_by_id(dynamodb_table):
65+
repository = TaskStore(table_name=dynamodb_table)
66+
task = Task.create(uuid.uuid4(), "Clean your office", "[email protected]")
67+
68+
repository.add(task)
69+
70+
assert repository.get_by_id(task_id=task.id, owner=task.owner) == task
71+
72+
73+
def test_open_tasks_listed(dynamodb_table):
74+
repository = TaskStore(table_name=dynamodb_table)
75+
open_task = Task.create(uuid.uuid4(), "Clean your office", "[email protected]")
76+
closed_task = Task(
77+
uuid.uuid4(), "Clean your office", TaskStatus.CLOSED, "[email protected]"
78+
)
79+
80+
repository.add(open_task)
81+
repository.add(closed_task)
82+
83+
assert repository.list_open(owner=open_task.owner) == [open_task]
84+
85+
86+
def test_closed_tasks_listed(dynamodb_table):
87+
repository = TaskStore(table_name=dynamodb_table)
88+
open_task = Task.create(uuid.uuid4(), "Clean your office", "[email protected]")
89+
closed_task = Task(
90+
uuid.uuid4(), "Clean your office", TaskStatus.CLOSED, "[email protected]"
91+
)
92+
93+
repository.add(open_task)
94+
repository.add(closed_task)
95+
96+
assert repository.list_closed(owner=open_task.owner) == [closed_task]

0 commit comments

Comments
 (0)