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 @@
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
-
-
-
-
-
-
-
-
-
-
-
- |
-
-
\ 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")