Skip to content
Merged
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
9 changes: 9 additions & 0 deletions lessons/276/compose.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
---
services:
app:
build: ./fastapi-app-simpler
ports:
- 8080:8080
depends_on:
- postgres
networks:
- private
postgres:
image: postgres:18.1
ports:
Expand All @@ -8,6 +16,7 @@ services:
POSTGRES_USER: admin
POSTGRES_DB: store
POSTGRES_PASSWORD: devops123
command: postgres -c 'max_connections=1000'
networks:
- private

Expand Down
27 changes: 27 additions & 0 deletions lessons/276/fastapi-app-simpler/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
*.pyc
*.pyo
*.mo
*.db
*.css.map
*.egg-info
*.sql.gz
.cache
.project
.idea
.pydevproject
.idea/workspace.xml
.DS_Store
.git/
.sass-cache
.vagrant/
__pycache__
dist
docs
env
logs
src/{{ project_name }}/settings/local.py
src/node_modules
web/media
web/static/CACHE
stats
Dockerfile
37 changes: 37 additions & 0 deletions lessons/276/fastapi-app-simpler/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
FROM python:3.14.2-trixie AS build

ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1

WORKDIR /app

RUN pip install --upgrade pip

COPY ./requirements.txt /app/requirements.txt

RUN pip install --no-cache-dir -r /app/requirements.txt

FROM python:3.14.2-slim-trixie

ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1

WORKDIR /app

RUN addgroup --system app && adduser --system --ingroup app app

COPY --from=build /usr/local/lib/python3.14/site-packages /usr/local/lib/python3.14/site-packages

COPY --from=build /usr/local/bin/granian /usr/local/bin/granian

COPY ./src /app
COPY ./config.yaml /app/config.yaml

RUN chown -R app:app /app

ENV PYTHONHASHSEED=random
ENV MALLOC_ARENA_MAX=2

USER app

CMD ["granian", "--port", "8080", "--interface", "asgi", "--host", "0.0.0.0", "--process-name", "fastapi speedrun", "main:app"]
9 changes: 9 additions & 0 deletions lessons/276/fastapi-app-simpler/config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
appPort: 8080
db:
user: admin
password: devops123
host: postgres
port: 5432
database: store
maxConnections: 100
50 changes: 50 additions & 0 deletions lessons/276/fastapi-app-simpler/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
annotated-doc==0.0.4
annotated-types==0.7.0
anyio==4.12.0
asyncpg==0.31.0
certifi==2025.11.12
cffi==2.0.0
click==8.3.1
dnspython==2.8.0
email-validator==2.3.0
fastapi[standard]==0.127.1
fastapi-cli==0.0.20
fastapi-cloud-cli==0.8.0
fastar==0.8.0
granian[pname]==2.6.0
h11==0.16.0
httpcore==1.0.9
httptools==0.7.1
httpx==0.28.1
idna==3.11
Jinja2==3.1.6
markdown-it-py==4.0.0
MarkupSafe==3.0.3
mdurl==0.1.2
orjson==3.11.5
packaging==25.0
pycparser==2.23
pydantic==2.12.5
pydantic-extra-types==2.10.6
pydantic-settings==2.12.0
pydantic_core==2.41.5
Pygments==2.19.2
python-dotenv==1.2.1
python-multipart==0.0.21
PyYAML==6.0.3
rich==14.2.0
rich-toolkit==0.17.1
rignore==0.7.6
sentry-sdk==2.48.0
setuptools==80.9.0
shellingham==1.5.4
socketify==0.0.31
starlette==0.50.0
typer==0.21.0
typing-inspection==0.4.2
typing_extensions==4.15.0
urllib3==2.6.2
uuid6==2025.0.1
uvloop==0.22.1
watchfiles==1.1.1
websockets==15.0.1
39 changes: 39 additions & 0 deletions lessons/276/fastapi-app-simpler/src/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import yaml


class Config:
def __init__(self, file_path):
self.app_port = int()
self.db = DBConfig()

self._load_config(file_path)

def _load_config(self, file_path):
try:
with open(file_path, "r") as file:
cfg = yaml.safe_load(file)

self.app_port = cfg["appPort"]
self.db.load(cfg["db"])
except FileNotFoundError:
print(f"Error: {file_path} not found.")
raise
except yaml.YAMLError as e:
print(f"Error parsing YAML: {e}")
raise


class DBConfig:
def __init__(self):
self.user = str()
self.password = str()
self.host = str()
self.database = str()
self.max_connections = int()

def load(self, cfg):
self.user = cfg["user"]
self.password = cfg["password"]
self.host = cfg["host"]
self.database = cfg["database"]
self.max_connections = cfg["maxConnections"]
59 changes: 59 additions & 0 deletions lessons/276/fastapi-app-simpler/src/db.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
from __future__ import annotations

import logging
from contextlib import asynccontextmanager
from typing import Annotated, AsyncGenerator

import asyncpg
from fastapi import Depends, FastAPI

from config import Config

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)


config = Config("config.yaml")


async def init_postgres() -> None:
global pool
try:
pool = await asyncpg.create_pool(
host=config.db.host,
user=config.db.user,
password=config.db.password,
database=config.db.database,
min_size=config.db.max_connections,
max_size=config.db.max_connections,
max_inactive_connection_lifetime=300,
)
except asyncpg.exceptions.PostgresError:
raise ValueError("Failed to create PostgreSQL connection pool")
except Exception:
raise


pool: asyncpg.Pool


async def get_db() -> AsyncGenerator[asyncpg.Connection, None]:
global pool
async with pool.acquire() as connection:
yield connection


PostgresDep = Annotated[asyncpg.Connection, Depends(get_db, use_cache=False)]


@asynccontextmanager
async def lifespan(app: FastAPI):
try:
await init_postgres()
yield
except Exception:
logger.exception("Failed to create database pool")
raise
finally:
await pool.close()
logger.info("Database connections closed")
91 changes: 91 additions & 0 deletions lessons/276/fastapi-app-simpler/src/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import datetime
import logging
from uuid6 import uuid7

from asyncpg import PostgresError
from fastapi import FastAPI, HTTPException
from fastapi.responses import ORJSONResponse, PlainTextResponse
from pydantic import BaseModel

from db import PostgresDep, lifespan

app = FastAPI(lifespan=lifespan)


logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)

INSERT_QUERY = """
INSERT INTO fastapi_app (name, address, phone, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5)
RETURNING id;
"""


@app.get("/healthz", response_class=PlainTextResponse)
async def health():
return "OK"


@app.get("/api/devices", response_class=ORJSONResponse)
async def get_devices():
devices = (
{
"uuid": uuid7(),
"mac": "5F-33-CC-1F-43-82",
"firmware": "2.1.6",
},
{
"uuid": uuid7(),
"mac": "EF-2B-C4-F5-D6-34",
"firmware": "2.1.5",
},
{
"uuid": uuid7(),
"mac": "62-46-13-B7-B3-A1",
"firmware": "3.0.0",
},
)

return devices


class UserRequest(BaseModel):
name: str
address: str
phone: str


@app.post("/api/users", status_code=201, response_class=ORJSONResponse)
async def create_user(user: UserRequest, conn: PostgresDep):
now = datetime.datetime.now(datetime.timezone.utc)
try:
row = await conn.fetchrow(
INSERT_QUERY, user.name, user.address, user.phone, now, now
)

if not row:
raise HTTPException(status_code=500, detail="Failed to create user record")

return {
"id": row["id"],
"name": user.name,
"address": user.address,
"phone": user.phone,
"created_at": now,
"updated_at": now,
}

except PostgresError:
logger.exception("Postgres error", extra={"user_data": user.model_dump()})
raise HTTPException(
status_code=500, detail="Database error occurred while creating user"
)

@app.exception_handler(Exception)
async def unhandled_exception_handler(request, exc):
logger.exception("Unhandled exception")
return ORJSONResponse(
status_code=500,
content={"detail": "Internal Server Error"},
)
Loading