diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..daeae90 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.gitignore +.venv +__pycache__ +.pytest_cache +*.pyc +*.pyo +*.pyd +*.swp +*.swo +tests diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3db82f0 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +DATABASE_URL=postgresql://postgres:postgres@db:5432/fastapi_template diff --git a/.gitignore b/.gitignore index e4e0ac0..1a587ee 100644 --- a/.gitignore +++ b/.gitignore @@ -128,4 +128,5 @@ dmypy.json # Pyre type checker .pyre/ -.idea \ No newline at end of file +.idea +test.db diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f57c640 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.10-slim + +WORKDIR /app + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md index 86a72ea..29a1732 100644 --- a/README.md +++ b/README.md @@ -1,88 +1,89 @@

FAST API

FastAPI Template

-

A template for the beginners

- +

A template for beginners

--- ## About -This is a beginner's template for getting started with FastAPI. -It uses SQLAlchemy as the ORM. - -Contributions are welcome. +This is a beginner-friendly template for getting started with FastAPI and SQLAlchemy. ## Features +- [x] Database connection using SQLAlchemy +- [x] FastAPI server +- [x] Unit testing with PyTest +- [x] Basic CRUD for posts -- [x] Database Connection Using SQLAlchemy -- [x] FastAPI Server -- [x] Unit Testing with PyTest -- [x] Basic CRUD for Posts +## Requirements +- Python 3.10+ +- `pip` +- PostgreSQL database -
+## Setup +1. Create and activate a virtual environment: -## Dependencies +```bash +python3 -m venv .venv +source .venv/bin/activate +``` + +2. Install dependencies: + +```bash +pip install -r requirements.txt +``` -- Python 3.7+ -- Pip -- Other listed in requirements.txt +3. Set environment variables: -## Running +| Key | Value | +| --- | --- | +| `DATABASE_URL` | `postgresql://user:password@host:port/db` | -- Clone the repo using +Example (`.env`): -```bash -git clone https://github.com/mdhishaamakhtar/fastapi-sqlalchemy-postgres-template +```env +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/fastapi_template ``` -- Create a Virtual Environment using +4. Run the API: ```bash -sudo pip install virtualenv -virtualenv env +uvicorn main:app --reload ``` -- Activate the virtualenv +## API Docs (Swagger / OpenAPI) +Once the app is running locally, open: +- Swagger UI: `http://127.0.0.1:8000/docs` +- ReDoc: `http://127.0.0.1:8000/redoc` +- OpenAPI JSON: `http://127.0.0.1:8000/openapi.json` +## Running tests ```bash -env\Scripts\activate # for windows -source env/bin/activate # for linux and mac +pytest ``` -- Install dependencies +## Formatting (Black) +Run formatter: ```bash -pip install -r requirements.txt +black . ``` -- Setting up environment variables +## Docker (Local) +1. Create `.env` from example: -| Key | Value | -| ----------- | ----------- | -| DATABASE_URL | postgresql://user:password@host:port/db| +```bash +cp .env.example .env +``` -- To run the project +2. Start API + Postgres with Docker Compose: ```bash -uvicorn main:app +docker compose up --build ``` -## Contributors - - - - - -
- Md Hishaam Akhtar -

- Md Hishaam Akhtar -

-

- - GitHub - - - LinkedIn - -

-
\ No newline at end of file +3. API will be available at: +- `http://127.0.0.1:8000` +- Swagger: `http://127.0.0.1:8000/docs` +- ReDoc: `http://127.0.0.1:8000/redoc` + +`DATABASE_URL` is passed to the API container from `.env` through `docker-compose.yml`. diff --git a/database/connection.py b/database/connection.py index f478c46..4ac3593 100644 --- a/database/connection.py +++ b/database/connection.py @@ -1,9 +1,10 @@ +from typing import cast + from decouple import config from sqlalchemy import create_engine -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import sessionmaker +from sqlalchemy.orm import declarative_base, sessionmaker -SQLALCHEMY_DATABASE_URL = config("DATABASE_URL") +SQLALCHEMY_DATABASE_URL = cast(str, config("DATABASE_URL")) engine = create_engine(SQLALCHEMY_DATABASE_URL) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..385ce0c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,33 @@ +services: + api: + build: + context: . + dockerfile: Dockerfile + container_name: fastapi_app + ports: + - "8000:8000" + environment: + DATABASE_URL: ${DATABASE_URL:-postgresql://postgres:postgres@db:5432/fastapi_template} + depends_on: + db: + condition: service_healthy + + db: + image: postgres:16-alpine + container_name: fastapi_db + environment: + POSTGRES_DB: fastapi_template + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d fastapi_template"] + interval: 5s + timeout: 5s + retries: 10 + +volumes: + postgres_data: diff --git a/requirements.txt b/requirements.txt index 904d938..bda7db1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,56 +1,33 @@ -aiofiles==0.5.0 -aniso8601==7.0.0 -appdirs==1.4.4 -async-exit-stack==1.0.1 -async-generator==1.10 -atomicwrites==1.4.0 -attrs==20.3.0 -black==20.8b1 -certifi==2020.12.5 -chardet==4.0.0 -click==7.1.2 -colorama==0.4.4 -dnspython==2.1.0 -email-validator==1.1.2 -fastapi==0.63.0 -graphene==2.1.8 -graphql-core==2.3.2 -graphql-relay==2.0.1 -greenlet==1.0.0 -h11==0.12.0 -idna==2.10 -iniconfig==1.1.1 -isort==5.8.0 -itsdangerous==1.1.0 -Jinja2==2.11.3 -MarkupSafe==1.1.1 -mypy-extensions==0.4.3 -orjson==3.5.2 -packaging==20.9 -pathspec==0.8.1 -pluggy==0.13.1 -promise==2.3 -psycopg2==2.8.6 -py==1.10.0 -pydantic==1.8.2 -pyparsing==2.4.7 -pytest==6.2.3 -pytest-dependency==0.5.1 -python-decouple==3.4 -python-dotenv==0.17.0 -python-multipart==0.0.5 -PyYAML==5.4.1 -regex==2021.4.4 -requests==2.25.1 -Rx==1.6.1 -six==1.15.0 -SQLAlchemy==1.4.11 -starlette==0.13.6 -toml==0.10.2 -typed-ast==1.4.3 -typing-extensions==3.7.4.3 -ujson==3.2.0 -urllib3==1.26.5 -uvicorn==0.13.4 -watchgod==0.7 -websockets==9.1 +Pygments==2.19.2 +SQLAlchemy==2.0.48 +annotated-doc==0.0.4 +annotated-types==0.7.0 +anyio==4.12.1 +black==26.1.0 +certifi==2026.2.25 +click==8.3.1 +exceptiongroup==1.3.1 +fastapi==0.135.1 +h11==0.16.0 +httpcore==1.0.9 +httpx==0.28.1 +idna==3.11 +iniconfig==2.3.0 +mypy_extensions==1.1.0 +packaging==26.0 +pathspec==1.0.4 +platformdirs==4.9.4 +pluggy==1.6.0 +psycopg2-binary==2.9.11 +pydantic==2.12.5 +pydantic_core==2.41.5 +pytest-dependency==0.6.1 +pytest==9.0.2 +python-decouple==3.8 +python-multipart==0.0.22 +pytokens==0.4.1 +starlette==0.52.1 +tomli==2.4.0 +typing-inspection==0.4.2 +typing_extensions==4.15.0 +uvicorn==0.41.0 diff --git a/routes/posts.py b/routes/posts.py index 0353abd..64dca2e 100644 --- a/routes/posts.py +++ b/routes/posts.py @@ -1,4 +1,5 @@ from typing import List +from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session @@ -27,14 +28,14 @@ def get_all_posts(db: Session = Depends(get_db)): @router.get("/get/{id}", status_code=status.HTTP_200_OK, response_model=Post) -def get_one_post(id, db: Session = Depends(get_db)): +def get_one_post(id: UUID, db: Session = Depends(get_db)): return post_get_one(db=db, id=id) @router.delete( "/delete/{id}", status_code=status.HTTP_200_OK, response_model=DeletePostResponse ) -def delete_post(id, db: Session = Depends(get_db)): +def delete_post(id: UUID, db: Session = Depends(get_db)): delete_status = post_delete(db=db, id=id) if delete_status.detail == "Doesnt Exist": raise HTTPException( diff --git a/schemas/models.py b/schemas/models.py index 8aa53cc..bedad4c 100644 --- a/schemas/models.py +++ b/schemas/models.py @@ -1,7 +1,7 @@ from typing import Optional from uuid import UUID -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict class HealthResponse(BaseModel): @@ -9,12 +9,11 @@ class HealthResponse(BaseModel): class Post(BaseModel): - id: Optional[UUID] + id: Optional[UUID] = None title: str description: str - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) class DeletePostResponse(BaseModel): @@ -26,5 +25,4 @@ class UpdatePost(BaseModel): title: str description: str - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) diff --git a/tests/test_posts.py b/tests/test_posts.py index 83e3e4f..bdc6277 100644 --- a/tests/test_posts.py +++ b/tests/test_posts.py @@ -1,75 +1,115 @@ -import pytest +import os +from uuid import uuid4 +from unittest.mock import patch + from fastapi import status from fastapi.testclient import TestClient +import pytest + +os.environ.setdefault("DATABASE_URL", "sqlite:///./test.db") +from database.connection import get_db from main import app +from schemas.models import DeletePostResponse, Post client = TestClient(app) -initial_post_title = "Hello" -initial_post_description = "World" -changed_post_description = "From the other side" +POST_ID = uuid4() +INITIAL_POST_TITLE = "Hello" +INITIAL_POST_DESCRIPTION = "World" +CHANGED_POST_DESCRIPTION = "From the other side" + + +@pytest.fixture(autouse=True) +def override_db_dependency(): + def fake_get_db(): + yield None + + app.dependency_overrides[get_db] = fake_get_db + yield + app.dependency_overrides.clear() + +def test_create_post(): + with patch( + "routes.posts.post_create", + return_value=Post( + id=POST_ID, title=INITIAL_POST_TITLE, description=INITIAL_POST_DESCRIPTION + ), + ): + response = client.post( + "/posts/create", + json={ + "title": INITIAL_POST_TITLE, + "description": INITIAL_POST_DESCRIPTION, + }, + ) -@pytest.mark.dependency() -def test_create_post(request): - response = client.post( - "/posts/create", - json={"title": initial_post_title, "description": initial_post_description}, - ) assert response.status_code == status.HTTP_201_CREATED - assert response.json()["title"] == "Hello" - assert response.json()["description"] == "World" - request.config.cache.set("post_id", response.json()["id"]) + assert response.json()["title"] == INITIAL_POST_TITLE + assert response.json()["description"] == INITIAL_POST_DESCRIPTION -@pytest.mark.dependency(depends=["test_create_post"]) def test_get_all_posts(): - response = client.get("/posts/list/all") + with patch( + "routes.posts.posts_get_all", + return_value=[ + Post( + id=POST_ID, + title=INITIAL_POST_TITLE, + description=INITIAL_POST_DESCRIPTION, + ) + ], + ): + response = client.get("/posts/list/all") + assert response.status_code == status.HTTP_200_OK - assert response.json() is not None + assert len(response.json()) == 1 + +def test_get_one_post(): + with patch( + "routes.posts.post_get_one", + return_value=Post( + id=POST_ID, title=INITIAL_POST_TITLE, description=INITIAL_POST_DESCRIPTION + ), + ): + response = client.get(f"/posts/get/{POST_ID}") -@pytest.mark.dependency(depends=["test_create_post"]) -def test_get_one_post(request): - post_id = request.config.cache.get("post_id", None) - response = client.get(f"/posts/get/{post_id}") assert response.status_code == status.HTTP_200_OK - assert response.json()["id"] == post_id - assert response.json()["title"] == initial_post_title - assert ( - response.json()["description"] == initial_post_description - or changed_post_description - ) - - -@pytest.mark.dependency(depends=["test_create_post", "test_get_one_post"]) -def test_patch_post(request): - post_id = request.config.cache.get("post_id", None) - response = client.patch( - "/posts/update", - json={ - "id": post_id, - "title": initial_post_title, - "description": changed_post_description, - }, - ) + assert response.json()["id"] == str(POST_ID) + assert response.json()["title"] == INITIAL_POST_TITLE + assert response.json()["description"] == INITIAL_POST_DESCRIPTION + + +def test_patch_post(): + with patch( + "routes.posts.post_update", + return_value=Post( + id=POST_ID, title=INITIAL_POST_TITLE, description=CHANGED_POST_DESCRIPTION + ), + ): + response = client.patch( + "/posts/update", + json={ + "id": str(POST_ID), + "title": INITIAL_POST_TITLE, + "description": CHANGED_POST_DESCRIPTION, + }, + ) + assert response.status_code == status.HTTP_200_OK - assert response.json()["id"] == post_id - assert response.json()["title"] == initial_post_title - assert response.json()["description"] == changed_post_description - - -@pytest.mark.dependency( - depends=[ - "test_create_post", - "test_get_one_post", - "test_patch_post", - "test_get_all_posts", - ] -) -def test_delete_post(request): - post_id = request.config.cache.get("post_id", None) - response = client.delete(f"/posts/delete/{post_id}") + assert response.json()["id"] == str(POST_ID) + assert response.json()["title"] == INITIAL_POST_TITLE + assert response.json()["description"] == CHANGED_POST_DESCRIPTION + + +def test_delete_post(): + with patch( + "routes.posts.post_delete", + return_value=DeletePostResponse(detail="Post Deleted"), + ): + response = client.delete(f"/posts/delete/{POST_ID}") + assert response.status_code == status.HTTP_200_OK assert response.json()["detail"] == "Post Deleted" diff --git a/utils/post_crud.py b/utils/post_crud.py index 791ecd2..712a453 100644 --- a/utils/post_crud.py +++ b/utils/post_crud.py @@ -1,5 +1,6 @@ from uuid import UUID +from sqlalchemy import delete, update from sqlalchemy.orm import Session from database.models import Posts @@ -23,8 +24,12 @@ def post_get_one(db: Session, id: UUID): def post_update(db: Session, post: UpdatePost): - update_query = {Posts.title: post.title, Posts.description: post.description} - db.query(Posts).filter_by(id=post.id).update(update_query) + stmt = ( + update(Posts) + .where(Posts.id == post.id) + .values(title=post.title, description=post.description) + ) + _ = db.execute(stmt) db.commit() return db.query(Posts).filter_by(id=post.id).one() @@ -33,6 +38,7 @@ def post_delete(db: Session, id: UUID): post = db.query(Posts).filter_by(id=id).all() if not post: return DeletePostResponse(detail="Doesnt Exist") - db.query(Posts).filter_by(id=id).delete() + stmt = delete(Posts).where(Posts.id == id) + _ = db.execute(stmt) db.commit() return DeletePostResponse(detail="Post Deleted")