Skip to content

Commit 253edc7

Browse files
committed
initial commit
1 parent 7261e9b commit 253edc7

File tree

8 files changed

+317
-106
lines changed

8 files changed

+317
-106
lines changed

app/components/todos/__init__.py

Whitespace-only changes.

app/components/todos/router.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
from typing import AsyncIterator, Never
2+
from fastapi import APIRouter, FastAPI
3+
from fastapi.concurrency import asynccontextmanager
4+
from pydantic import BaseModel
5+
6+
from app.redis import redis
7+
from app.components.todos.store import Todo, TodoDocument, TodoStatus, TodoStore, Todos
8+
9+
todos = TodoStore(redis)
10+
11+
@asynccontextmanager
12+
async def lifespan(_: FastAPI) -> AsyncIterator[Never]:
13+
# before
14+
await todos.initialize()
15+
yield # type: ignore
16+
# after
17+
return
18+
19+
router = APIRouter(lifespan=lifespan)
20+
21+
@router.get("/", tags=["todos"])
22+
async def all() -> Todos:
23+
return await todos.all()
24+
25+
@router.get("/search", tags=["todos"])
26+
async def search(name: str | None = None, status: TodoStatus | None = None) -> Todos:
27+
return await todos.search(name, status)
28+
29+
@router.get("/{id}", tags=["todos"])
30+
async def one(id: str) -> Todo:
31+
return await todos.one(id)
32+
33+
class CreateTodo(BaseModel):
34+
id: str | None = None
35+
name: str
36+
37+
@router.post("/", tags=["todos"])
38+
async def create(todo: CreateTodo) -> TodoDocument:
39+
return await todos.create(todo.id, todo.name)
40+
41+
class UpdateTodo(BaseModel):
42+
status: TodoStatus
43+
44+
@router.patch("/{id}", tags=["todos"])
45+
async def update(id: str, todo: UpdateTodo) -> Todo:
46+
return await todos.update(id, todo.status)
47+
48+
@router.delete("/{id}", tags=["todos"])
49+
async def delete(id: str) -> None:
50+
return await todos.delete(id)

app/components/todos/store.py

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
import re
2+
from datetime import datetime
3+
from enum import Enum
4+
from typing import Any, List, Optional
5+
from uuid import uuid4
6+
7+
from pydantic import BaseModel, Field, TypeAdapter, field_serializer
8+
from pydantic_core import from_json
9+
from redis.asyncio import Redis
10+
from redis.commands.search.document import Document
11+
from redis.commands.search.field import TextField
12+
from redis.commands.search.query import Query
13+
from redis.commands.search.indexDefinition import IndexDefinition, IndexType
14+
from redis.exceptions import ResponseError
15+
16+
from app.logger import logger
17+
18+
TODOS_INDEX = "todos-idx"
19+
TODOS_PREFIX = "todos:"
20+
21+
22+
class TodoStatus(str, Enum):
23+
todo = 'todo'
24+
in_progress = 'in progress'
25+
complete = 'complete'
26+
27+
28+
class Todo(BaseModel):
29+
name: str
30+
status: TodoStatus
31+
created_date: datetime = None
32+
updated_date: datetime = None
33+
34+
@field_serializer('created_date')
35+
def serialize_created_date(self, dt: datetime, _info):
36+
return dt.strftime('%Y-%m-%dT%H:%M:%SZ')
37+
38+
@field_serializer('updated_date')
39+
def serialize_updated_date(self, dt: datetime, _info):
40+
return dt.strftime('%Y-%m-%dT%H:%M:%SZ')
41+
42+
class TodoDocument(BaseModel):
43+
id: str
44+
value: Todo
45+
46+
# def __init__(self, doc: Any, test: Any):
47+
# print("test")
48+
# print(doc)
49+
# super().__init__(id=doc.id, document=Todo(**from_json(doc.json, allow_partial=True)))
50+
51+
class Todos(BaseModel):
52+
total: int
53+
documents: List[TodoDocument]
54+
55+
class TodoStore:
56+
"""Stores todos"""
57+
58+
def __init__(self, redis: Redis):
59+
self.redis = redis
60+
self.INDEX = TODOS_INDEX
61+
self.PREFIX = TODOS_PREFIX
62+
63+
async def initialize(self) -> None:
64+
await self.create_index_if_not_exists()
65+
return None
66+
67+
async def have_index(self) -> bool:
68+
try:
69+
info = await self.redis.ft(self.INDEX).info() # type: ignore
70+
except ResponseError as e:
71+
if "Unknown index name" in str(e):
72+
logger.info(f'Index {self.INDEX} does not exist')
73+
return False
74+
75+
logger.info(f"Index {self.INDEX} already exists")
76+
return True
77+
78+
async def create_index_if_not_exists(self) -> None:
79+
if await self.have_index():
80+
return None
81+
82+
logger.debug(f"Creating index {self.INDEX}")
83+
84+
schema = (
85+
TextField("$.name", as_name="name"),
86+
TextField("$.status", as_name="status"),
87+
)
88+
89+
try:
90+
await self.redis.ft(self.INDEX).create_index( # type: ignore
91+
schema,
92+
definition=IndexDefinition( # type: ignore
93+
prefix=[TODOS_PREFIX], index_type=IndexType.JSON
94+
),
95+
)
96+
except Exception as e:
97+
logger.error(f"Error setting up index {self.INDEX}: {e}")
98+
raise
99+
100+
logger.debug(f"Index {self.INDEX} created successfully")
101+
102+
return None
103+
104+
async def drop_index(self) -> None:
105+
if not await self.have_index():
106+
return None
107+
108+
try:
109+
await self.redis.ft(self.INDEX).dropindex()
110+
except Exception as e:
111+
logger.error(f"Error dropping index ${self.INDEX}: {e}")
112+
raise
113+
114+
logger.debug(f"Index {self.INDEX} dropped successfully")
115+
116+
return None
117+
118+
def format_id(self, id: str) -> str:
119+
if re.match(f'^{self.PREFIX}', id):
120+
return id
121+
122+
return f'{self.PREFIX}{id}'
123+
124+
def parse_todo_document(self, todo: Document) -> TodoDocument:
125+
return TodoDocument(id=todo.id, value=Todo(**from_json(todo.json, allow_partial=True)))
126+
127+
def parse_todo_documents(self, todos: list[Document]) -> Todos:
128+
todo_docs = [];
129+
130+
for doc in todos:
131+
todo_docs.append(self.parse_todo_document(doc))
132+
133+
return todo_docs
134+
135+
async def all(self):
136+
try:
137+
result = await self.redis.ft(self.INDEX).search("*")
138+
return Todos(total=result.total, documents=self.parse_todo_documents(result.docs))
139+
except Exception as e:
140+
logger.error(f"Error getting all todos: {e}")
141+
raise
142+
143+
async def one(self, id: str) -> Todo:
144+
id = self.format_id(id)
145+
146+
try:
147+
json = await self.redis.json().get(id)
148+
except Exception as e:
149+
logger.error(f"Error getting todo ${id}: {e}")
150+
raise
151+
152+
return Todo(**json)
153+
154+
async def search(self, name: str | None, status: TodoStatus | None) -> Todo:
155+
searches = []
156+
157+
if name is not None and len(name) > 0:
158+
searches.append(f'@name:({name})')
159+
160+
if status is not None and len(status) > 0:
161+
searches.append(f'@status:{status.value}')
162+
163+
try:
164+
result = await self.redis.ft(self.INDEX).search(Query(' '.join(searches)))
165+
return Todos(total=result.total, documents=self.parse_todo_documents(result.docs))
166+
except Exception as e:
167+
logger.error(f"Error getting todo {id}: {e}")
168+
raise
169+
170+
async def create(self, id: Optional[str], name: Optional[str]) -> TodoDocument:
171+
dt = datetime.now()
172+
173+
if name is None:
174+
raise Exception("Todo must have a name")
175+
176+
if id is None:
177+
id = str(uuid4())
178+
179+
todo = TodoDocument(**{
180+
"id": self.format_id(id),
181+
"value": {
182+
"name": name,
183+
"status": "todo",
184+
"created_date": dt,
185+
"updated_date": dt,
186+
}
187+
})
188+
189+
try:
190+
result = await self.redis.json().set(todo.id, "$", todo.value.model_dump())
191+
except Exception as e:
192+
logger.error(f'Error creating todo {todo}: {e}')
193+
raise
194+
195+
if result != True:
196+
raise Exception(f'Error creating todo {todo}')
197+
198+
return todo
199+
200+
async def update(self, id: str, status: str) -> Todo:
201+
dt = datetime.now()
202+
203+
todo = await self.one(id)
204+
205+
todo.status = status
206+
todo.updated_date = dt
207+
208+
try:
209+
result = await self.redis.json().set(self.format_id(id), "$", todo.model_dump())
210+
except Exception as e:
211+
logger.error(f'Error updating todo {todo}: {e}')
212+
raise
213+
214+
if result != True:
215+
raise Exception(f'Error creating todo {todo}')
216+
217+
return todo
218+
219+
async def delete(self, id: str) -> None:
220+
try:
221+
await self.redis.json().delete(self.format_id(id))
222+
except Exception as e:
223+
logger.error(f'Error deleting todo {id}: {e}')
224+
raise
225+
226+
return None
227+
228+
async def delete_all(self) -> None:
229+
todos = await self.all()
230+
231+
try:
232+
for todo in todos.documents:
233+
await self.redis.json().delete(todo.id)
234+
except Exception as e:
235+
logger.error(f'Error deleting todos: {e}')
236+
raise
237+
238+
return None

app/main.py

Lines changed: 3 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,35 +3,12 @@
33

44
import os
55
from contextlib import asynccontextmanager
6-
from typing import Any, AsyncIterator, Never, Union
76

87
from fastapi import FastAPI
98

109
import app.redis as redis
11-
from app.todos import Todos
10+
from app.components.todos.router import router as todos_router
1211

13-
redis_url = os.environ.get("REDIS_URL", "redis://localhost:6379")
14-
client = redis.get_client(redis_url)
15-
todos = Todos(client)
1612

17-
18-
@asynccontextmanager
19-
async def lifespan(_: FastAPI) -> AsyncIterator[Never]:
20-
# before
21-
await todos.initialize()
22-
yield # type: ignore
23-
# after
24-
return
25-
26-
27-
app = FastAPI(lifespan=lifespan)
28-
29-
30-
@app.get("/")
31-
async def read_root() -> bool:
32-
return await todos.have_index()
33-
34-
35-
@app.get("/items/{item_id}")
36-
def read_item(item_id: int, q: Union[str, None] = None) -> Any:
37-
return {"item_id": item_id, "q": q}
13+
app = FastAPI()
14+
app.include_router(router=todos_router, prefix="/api/todos")

app/redis.py

Lines changed: 9 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import os
12
from typing import Optional
23

34
from redis.asyncio import Redis
@@ -7,20 +8,11 @@
78

89
from app.logger import logger
910

10-
client: Optional[Redis] = None
11-
12-
13-
def get_client(url: str = "redis://localhost:6379") -> Redis:
14-
global client
15-
16-
if client is None:
17-
logger.info(f"Creating redis client for url: {url}")
18-
client = Redis.from_url(
19-
url,
20-
decode_responses=True,
21-
retry=Retry(ExponentialBackoff(cap=10, base=1), 25),
22-
retry_on_error=[ConnectionError, TimeoutError, ConnectionResetError],
23-
health_check_interval=1,
24-
)
25-
26-
return client
11+
redis_url = os.environ.get("REDIS_URL", "redis://localhost:6379")
12+
redis = Redis.from_url(
13+
redis_url,
14+
decode_responses=True,
15+
retry=Retry(ExponentialBackoff(cap=10, base=1), 25),
16+
retry_on_error=[ConnectionError, TimeoutError, ConnectionResetError],
17+
health_check_interval=1,
18+
)

0 commit comments

Comments
 (0)