From c57dc3f75b65d531467f31eb6c3f0671710db083 Mon Sep 17 00:00:00 2001 From: Lewis Cowles Date: Fri, 2 Jan 2026 18:37:42 +0000 Subject: [PATCH 1/2] refactor: make separate directory for changed fastapi --- lessons/276/compose.yaml | 9 ++ lessons/276/fastapi-app-simpler/.dockerignore | 27 ++++++ lessons/276/fastapi-app-simpler/Dockerfile | 37 ++++++++ lessons/276/fastapi-app-simpler/config.yaml | 9 ++ .../276/fastapi-app-simpler/requirements.txt | 51 +++++++++++ lessons/276/fastapi-app-simpler/src/config.py | 39 ++++++++ lessons/276/fastapi-app-simpler/src/db.py | 59 ++++++++++++ lessons/276/fastapi-app-simpler/src/main.py | 91 +++++++++++++++++++ lessons/276/script.js | 69 ++++++++++++++ 9 files changed, 391 insertions(+) create mode 100644 lessons/276/fastapi-app-simpler/.dockerignore create mode 100644 lessons/276/fastapi-app-simpler/Dockerfile create mode 100644 lessons/276/fastapi-app-simpler/config.yaml create mode 100644 lessons/276/fastapi-app-simpler/requirements.txt create mode 100644 lessons/276/fastapi-app-simpler/src/config.py create mode 100644 lessons/276/fastapi-app-simpler/src/db.py create mode 100644 lessons/276/fastapi-app-simpler/src/main.py create mode 100644 lessons/276/script.js diff --git a/lessons/276/compose.yaml b/lessons/276/compose.yaml index 69f3086a..f9d955f0 100644 --- a/lessons/276/compose.yaml +++ b/lessons/276/compose.yaml @@ -1,5 +1,13 @@ --- services: + app: + build: ./fastapi-app-simpler + ports: + - 8080:8080 + depends_on: + - postgres + networks: + - private postgres: image: postgres:18.1 ports: @@ -8,6 +16,7 @@ services: POSTGRES_USER: admin POSTGRES_DB: store POSTGRES_PASSWORD: devops123 + command: postgres -c 'max_connections=1000' networks: - private diff --git a/lessons/276/fastapi-app-simpler/.dockerignore b/lessons/276/fastapi-app-simpler/.dockerignore new file mode 100644 index 00000000..de077617 --- /dev/null +++ b/lessons/276/fastapi-app-simpler/.dockerignore @@ -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 diff --git a/lessons/276/fastapi-app-simpler/Dockerfile b/lessons/276/fastapi-app-simpler/Dockerfile new file mode 100644 index 00000000..5f8246c2 --- /dev/null +++ b/lessons/276/fastapi-app-simpler/Dockerfile @@ -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/gunicorn /usr/local/bin/gunicorn + +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 ["gunicorn", "-w", "1", "-k", "uvicorn.workers.UvicornWorker", "--preload", "--timeout", "10", "--graceful-timeout", "5", "--log-level", "error", "main:app", "--bind", "0.0.0.0:8080"] diff --git a/lessons/276/fastapi-app-simpler/config.yaml b/lessons/276/fastapi-app-simpler/config.yaml new file mode 100644 index 00000000..d1eba877 --- /dev/null +++ b/lessons/276/fastapi-app-simpler/config.yaml @@ -0,0 +1,9 @@ +--- +appPort: 8080 +db: + user: admin + password: devops123 + host: postgres + port: 5432 + database: store + maxConnections: 100 diff --git a/lessons/276/fastapi-app-simpler/requirements.txt b/lessons/276/fastapi-app-simpler/requirements.txt new file mode 100644 index 00000000..ab93a3ac --- /dev/null +++ b/lessons/276/fastapi-app-simpler/requirements.txt @@ -0,0 +1,51 @@ +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 +gunicorn==23.0.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 +uvicorn==0.40.0 +uvloop==0.22.1 +watchfiles==1.1.1 +websockets==15.0.1 diff --git a/lessons/276/fastapi-app-simpler/src/config.py b/lessons/276/fastapi-app-simpler/src/config.py new file mode 100644 index 00000000..e272f635 --- /dev/null +++ b/lessons/276/fastapi-app-simpler/src/config.py @@ -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"] diff --git a/lessons/276/fastapi-app-simpler/src/db.py b/lessons/276/fastapi-app-simpler/src/db.py new file mode 100644 index 00000000..c4213c4f --- /dev/null +++ b/lessons/276/fastapi-app-simpler/src/db.py @@ -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") diff --git a/lessons/276/fastapi-app-simpler/src/main.py b/lessons/276/fastapi-app-simpler/src/main.py new file mode 100644 index 00000000..3b8b88e9 --- /dev/null +++ b/lessons/276/fastapi-app-simpler/src/main.py @@ -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"}, + ) diff --git a/lessons/276/script.js b/lessons/276/script.js new file mode 100644 index 00000000..e270af9b --- /dev/null +++ b/lessons/276/script.js @@ -0,0 +1,69 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import faker from 'https://cdnjs.cloudflare.com/ajax/libs/Faker/3.1.0/faker.min.js'; + +export const options = { + // A number specifying the number of VUs to run concurrently. + vus: 100, + // A string specifying the total duration of the test run. + duration: '30s', + + // The following section contains configuration options for execution of this + // test script in Grafana Cloud. + // + // See https://grafana.com/docs/grafana-cloud/k6/get-started/run-cloud-tests-from-the-cli/ + // to learn about authoring and running k6 test scripts in Grafana k6 Cloud. + // + // cloud: { + // // The ID of the project to which the test is assigned in the k6 Cloud UI. + // // By default tests are executed in default project. + // projectID: "", + // // The name of the test in the k6 Cloud UI. + // // Test runs with the same name will be grouped. + // name: "script.js" + // }, + + // Uncomment this section to enable the use of Browser API in your tests. + // + // See https://grafana.com/docs/k6/latest/using-k6-browser/running-browser-tests/ to learn more + // about using Browser API in your test scripts. + // + // scenarios: { + // // The scenario name appears in the result summary, tags, and so on. + // // You can give the scenario any name, as long as each name in the script is unique. + // ui: { + // // Executor is a mandatory parameter for browser-based tests. + // // Shared iterations in this case tells k6 to reuse VUs to execute iterations. + // // + // // See https://grafana.com/docs/k6/latest/using-k6/scenarios/executors/ for other executor types. + // executor: 'shared-iterations', + // options: { + // browser: { + // // This is a mandatory parameter that instructs k6 to launch and + // // connect to a chromium-based browser, and use it to run UI-based + // // tests. + // type: 'chromium', + // }, + // }, + // }, + // } +}; + +// The function that defines VU logic. +// +// See https://grafana.com/docs/k6/latest/examples/get-started-with-k6/ to learn more +// about authoring k6 scripts. +// +export default function() { + let response = http.post('http://localhost:8080/api/users', JSON.stringify( + { + name: faker.name.firstName() + ' ' + faker.name.lastName(), + address: faker.address.streetAddress(), + phone: faker.phone.phoneNumber('+##############'), + } + )); + check(response, { + 'is status 201': (r) => r.status === 201, + }); + // sleep(1); +} From 7e55c3312b5d53c1edba9d4f5c6dd70306bbbf56 Mon Sep 17 00:00:00 2001 From: Lewis Cowles Date: Fri, 2 Jan 2026 18:48:28 +0000 Subject: [PATCH 2/2] chore: granian instead of uvicorn --- lessons/276/fastapi-app-simpler/Dockerfile | 4 ++-- lessons/276/fastapi-app-simpler/requirements.txt | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/lessons/276/fastapi-app-simpler/Dockerfile b/lessons/276/fastapi-app-simpler/Dockerfile index 5f8246c2..9bd19b66 100644 --- a/lessons/276/fastapi-app-simpler/Dockerfile +++ b/lessons/276/fastapi-app-simpler/Dockerfile @@ -22,7 +22,7 @@ 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/gunicorn /usr/local/bin/gunicorn +COPY --from=build /usr/local/bin/granian /usr/local/bin/granian COPY ./src /app COPY ./config.yaml /app/config.yaml @@ -34,4 +34,4 @@ ENV MALLOC_ARENA_MAX=2 USER app -CMD ["gunicorn", "-w", "1", "-k", "uvicorn.workers.UvicornWorker", "--preload", "--timeout", "10", "--graceful-timeout", "5", "--log-level", "error", "main:app", "--bind", "0.0.0.0:8080"] +CMD ["granian", "--port", "8080", "--interface", "asgi", "--host", "0.0.0.0", "--process-name", "fastapi speedrun", "main:app"] \ No newline at end of file diff --git a/lessons/276/fastapi-app-simpler/requirements.txt b/lessons/276/fastapi-app-simpler/requirements.txt index ab93a3ac..2809a16d 100644 --- a/lessons/276/fastapi-app-simpler/requirements.txt +++ b/lessons/276/fastapi-app-simpler/requirements.txt @@ -11,7 +11,7 @@ fastapi[standard]==0.127.1 fastapi-cli==0.0.20 fastapi-cloud-cli==0.8.0 fastar==0.8.0 -gunicorn==23.0.0 +granian[pname]==2.6.0 h11==0.16.0 httpcore==1.0.9 httptools==0.7.1 @@ -45,7 +45,6 @@ typing-inspection==0.4.2 typing_extensions==4.15.0 urllib3==2.6.2 uuid6==2025.0.1 -uvicorn==0.40.0 uvloop==0.22.1 watchfiles==1.1.1 websockets==15.0.1