Skip to content

Commit 8170931

Browse files
committed
feat: add minimal database setup with SQLite and CRUD operations for heroes
1 parent 3c8483a commit 8170931

14 files changed

Lines changed: 645 additions & 26 deletions

File tree

.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,14 @@ SERVICE_NAME="FastAPI Service"
33
HOST=0.0.0.0
44
PORT=8080
55

6+
# Database Configuration
7+
DATABASE_URL=sqlite:///database.db
8+
# DATABASE_URL=postgresql://develop:develop_secret@localhost:5432/develop
9+
610
# Logging Configuration
711
LOG_LEVEL=INFO
812
LOG_SAVE_ON_FILE=false
13+
LOG_DATABASE_QUERIES=false
914

1015
# Authentication and Authorization
1116
API_KEY=your-secret-api-key-here

pyproject.toml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@ description = "Add your description here"
55
readme = "README.md"
66
requires-python = ">=3.10,<3.11"
77
dependencies = [
8-
"fastapi[standard]>=0.112.1",
9-
"uvicorn==0.30.5",
10-
"pydantic-settings==2.3.4"
8+
"fastapi[standard]>=0.124.4",
9+
"uvicorn==0.38.0",
10+
"pydantic-settings==2.12.0",
11+
"sqlmodel~=0.0.27",
1112
]
1213

1314
[dependency-groups]

src/api/deps.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,29 @@
1+
from typing import Annotated, Generator
2+
13
from config import settings
4+
from database import engine
25
from fastapi import Depends, HTTPException
36
from fastapi.security import APIKeyHeader
7+
from sqlmodel import Session
48
from starlette import status
59

610
"""
711
Defines dependencies used by the endpoints.
812
"""
913

1014

11-
def api_key_auth(api_key: str = Depends(APIKeyHeader(name="Authorization"))):
15+
def get_db_session() -> Generator[Session, None, None]:
16+
"""
17+
Create a new database session and close the session after the operation has ended.
18+
"""
19+
with Session(engine) as session:
20+
yield session
21+
22+
23+
SessionDep = Annotated[Session, Depends(get_db_session)]
24+
25+
26+
def api_key_auth(api_key: str = Depends(APIKeyHeader(name="Authorization"))) -> None:
1227
# Validate the provided API key
1328
if api_key != settings.API_KEY.get_secret_value():
1429
raise HTTPException(

src/api/endpoints/heroes.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
from typing import Annotated
2+
3+
from api.deps import SessionDep, get_db_session
4+
from excepts import DatabaseEntryNotFound, get_error_content
5+
from fastapi import APIRouter, Depends, HTTPException, Query, status
6+
from models import Hero, HeroCreate, HeroPublic, HeroUpdate
7+
from sqlmodel import select
8+
from utils.log import get_logger
9+
10+
logger = get_logger()
11+
router = APIRouter()
12+
13+
14+
@router.post(
15+
path="/",
16+
summary="Create a new hero",
17+
status_code=status.HTTP_200_OK,
18+
response_description="Returns the created hero",
19+
response_model=HeroPublic,
20+
dependencies=[Depends(get_db_session)],
21+
)
22+
def create_hero(hero: HeroCreate, session: SessionDep):
23+
db_hero = Hero.model_validate(hero)
24+
session.add(db_hero)
25+
session.commit()
26+
session.refresh(db_hero)
27+
return db_hero
28+
29+
30+
@router.get(
31+
path="/",
32+
summary="Retrieve a list of heroes",
33+
status_code=status.HTTP_200_OK,
34+
response_description="Returns a list of heroes",
35+
response_model=list[HeroPublic],
36+
dependencies=[Depends(get_db_session)],
37+
)
38+
def read_heroes(
39+
session: SessionDep,
40+
offset: int = 0,
41+
limit: Annotated[int, Query(le=100)] = 100,
42+
) -> list[Hero]:
43+
heroes = session.exec(select(Hero).offset(offset).limit(limit)).all()
44+
return list(heroes)
45+
46+
47+
@router.get(
48+
path="/{hero_id}",
49+
summary="Retrieve a hero by ID",
50+
status_code=status.HTTP_200_OK,
51+
response_description="Returns the hero with the specified ID",
52+
response_model=HeroPublic,
53+
dependencies=[Depends(get_db_session)],
54+
)
55+
def read_hero(hero_id: int, session: SessionDep):
56+
try:
57+
hero = session.get(Hero, hero_id)
58+
if not hero:
59+
raise DatabaseEntryNotFound(f"Hero with ID {hero_id} not found")
60+
return hero
61+
62+
except Exception as e:
63+
error = get_error_content(e)
64+
error_message = error.message
65+
66+
logger.error(
67+
error_message,
68+
exc_info=True,
69+
stack_info=True,
70+
)
71+
72+
raise HTTPException(
73+
status_code=error.http_status_code,
74+
detail=error_message,
75+
)
76+
77+
78+
@router.patch(
79+
path="/{hero_id}",
80+
summary="Update a hero by ID",
81+
status_code=status.HTTP_200_OK,
82+
response_description="Returns the updated hero",
83+
response_model=HeroPublic,
84+
dependencies=[Depends(get_db_session)],
85+
)
86+
def update_hero(hero_id: int, hero: HeroUpdate, session: SessionDep):
87+
try:
88+
hero_db = session.get(Hero, hero_id)
89+
if not hero_db:
90+
raise DatabaseEntryNotFound(f"Hero with ID {hero_id} not found")
91+
hero_data = hero.model_dump(exclude_unset=True)
92+
hero_db.sqlmodel_update(hero_data)
93+
session.add(hero_db)
94+
session.commit()
95+
session.refresh(hero_db)
96+
return hero_db
97+
98+
except Exception as e:
99+
error = get_error_content(e)
100+
error_message = error.message
101+
102+
logger.error(
103+
error_message,
104+
exc_info=True,
105+
stack_info=True,
106+
)
107+
108+
raise HTTPException(
109+
status_code=error.http_status_code,
110+
detail=error_message,
111+
)
112+
113+
114+
@router.delete(
115+
path="/{hero_id}",
116+
summary="Delete a hero by ID",
117+
status_code=status.HTTP_200_OK,
118+
response_description="Indicates whether the hero was successfully deleted",
119+
dependencies=[Depends(get_db_session)],
120+
)
121+
def delete_hero(hero_id: int, session: SessionDep):
122+
try:
123+
hero = session.get(Hero, hero_id)
124+
if not hero:
125+
raise DatabaseEntryNotFound("Hero not found")
126+
session.delete(hero)
127+
session.commit()
128+
return {"ok": True}
129+
except Exception as e:
130+
error = get_error_content(e)
131+
error_message = error.message
132+
133+
logger.error(
134+
error_message,
135+
exc_info=True,
136+
stack_info=True,
137+
)
138+
139+
raise HTTPException(
140+
status_code=error.http_status_code,
141+
detail=error_message,
142+
)

src/api/router.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
1-
from api.endpoints import (
2-
health,
3-
users,
4-
)
1+
from api.endpoints import health, heroes, users
52
from fastapi import APIRouter
63

74
router = APIRouter()
85
router.include_router(health.router, prefix="", tags=["health"])
96
router.include_router(users.router, prefix="/users", tags=["users"])
7+
router.include_router(heroes.router, prefix="/heroes", tags=["heroes"])

src/config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,13 @@ class Settings(BaseSettings):
2929
f"http://localhost:{PORT}",
3030
]
3131

32+
# Database Configuration
33+
DATABASE_URL: str = "sqlite:///database.db"
34+
3235
# Logging Configuration
3336
LOG_LEVEL: str = "INFO"
3437
LOG_SAVE_ON_FILE: bool = False
38+
LOG_DATABASE_QUERIES: bool = False
3539

3640
# Authentication and Authorization
3741
API_KEY: SecretStr

src/database.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
from config import settings
2+
from sqlalchemy import Engine
3+
from sqlmodel import SQLModel, create_engine
4+
5+
6+
def create_db_engine(verbose: bool = False, **kwargs):
7+
"""
8+
Create the SQLAlchemy engine for database interactions.
9+
10+
Args:
11+
verbose (bool): If True, enables SQL query logging.
12+
**kwargs: Additional keyword arguments for the engine.
13+
Returns:
14+
engine: The SQLAlchemy engine instance.
15+
"""
16+
# Using check_same_thread=False allows FastAPI to use the same SQLite database in different threads.
17+
connect_args = {
18+
"check_same_thread": False,
19+
}
20+
engine = create_engine(
21+
settings.DATABASE_URL,
22+
connect_args=connect_args,
23+
**{
24+
"echo": verbose,
25+
"pool_use_lifo": True, # Avoid many idle connections
26+
"pool_pre_ping": True, # Gracefully handle connections closed by the server
27+
**kwargs,
28+
},
29+
)
30+
return engine
31+
32+
33+
def create_db_and_tables(engine: Engine):
34+
"""
35+
Create database tables based on the defined SQLModel models.
36+
"""
37+
SQLModel.metadata.create_all(engine)
38+
39+
40+
engine = create_db_engine()

src/excepts.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,18 @@ class InvalidValue(BackendException):
4242
http_status_code: int = status.HTTP_422_UNPROCESSABLE_ENTITY
4343

4444

45+
class DatabaseException(BackendException): ...
46+
47+
48+
class DatabaseEntryNotFound(DatabaseException):
49+
"""
50+
Raised when a database entry is not found.
51+
"""
52+
53+
default_message = "The requested database entry was not found"
54+
http_status_code: int = status.HTTP_404_NOT_FOUND
55+
56+
4557
# Dictionary of errors that we want to propagate and expose to the API users
4658
ERRORS = {
4759
ValueRequired: ErrorContent(
@@ -50,6 +62,10 @@ class InvalidValue(BackendException):
5062
InvalidValue: ErrorContent(
5163
InvalidValue.default_message, InvalidValue.http_status_code
5264
),
65+
DatabaseEntryNotFound: ErrorContent(
66+
DatabaseEntryNotFound.default_message,
67+
DatabaseEntryNotFound.http_status_code,
68+
),
5369
}
5470

5571

src/main.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from api.exception_handlers import validation_exception_handler
66
from api.router import router
77
from config import init_whatever, settings
8+
from database import create_db_and_tables, engine
89
from fastapi import FastAPI
910
from fastapi.exceptions import RequestValidationError
1011
from fastapi.middleware.cors import CORSMiddleware
@@ -19,7 +20,14 @@ async def lifespan(app: FastAPI):
1920
# It may be stored in app state object:
2021
# app.state.some_resource = SomeResource()
2122
# https://github.com/fastapi/fastapi/discussions/13029
23+
24+
# TODO: For production you would probably use an Alembic migration script that runs before you start your app.
25+
# This is just for demonstration purposes.
26+
# https://alembic.sqlalchemy.org/en/latest/
27+
create_db_and_tables(engine)
2228
yield
29+
# Clean up
30+
engine.dispose()
2331

2432

2533
app = FastAPI(title=settings.SERVICE_NAME, version="0.1.0", lifespan=lifespan)

src/models.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
from sqlmodel import Field, SQLModel
2+
3+
4+
class HeroBase(SQLModel):
5+
"""Base model for Hero with common attributes."""
6+
7+
name: str = Field(title="Name", description="The public name of the hero.")
8+
age: int | None = Field(
9+
default=None, title="Age", description="The age of the hero."
10+
)
11+
12+
13+
class Hero(HeroBase, table=True):
14+
"""Database model for Hero with the extra fields that are not always in the other models."""
15+
16+
id: int | None = Field(
17+
default=None,
18+
title="Hero ID",
19+
description="The unique identifier for the hero.",
20+
primary_key=True,
21+
)
22+
secret_name: str = Field(
23+
title="Secret Name", description="The secret identity of the hero."
24+
)
25+
26+
27+
class HeroPublic(HeroBase):
28+
"""Public model for Hero without sensitive information."""
29+
30+
id: int
31+
32+
33+
class HeroCreate(HeroBase):
34+
"""Model for creating a new Hero."""
35+
36+
secret_name: str
37+
38+
39+
class HeroUpdate(HeroBase):
40+
"""Model for updating an existing Hero.
41+
We don't really need to inherit from HeroBase because we are re-declaring all the fields.
42+
I'll leave it inheriting just for consistency, but this is not necessary.
43+
"""
44+
45+
name: str | None = None
46+
age: int | None = None
47+
secret_name: str | None = None

0 commit comments

Comments
 (0)