├── .gitignore ├── Makefile ├── README.md ├── app ├── __init__.py ├── config.py ├── database.py ├── main.py ├── models.py ├── note.py └── schemas.py ├── example.env └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | venv/ 3 | .env 4 | *.db 5 | docker-compose.yml 6 | Dockerfile 7 | Makefile1 -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | dev: 2 | docker-compose up -d 3 | 4 | dev-down: 5 | docker-compose down 6 | 7 | server: 8 | uvicorn app.main:app --reload -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Build a CRUD App with FastAPI and SQLAlchemy 2 | 3 | In this article, I'll provide you with a simple and straightforward guide on how you can build a CRUD app with FastAPI and SQLAlchemy. The FastAPI app will run on a Starlette web server, use Pydantic for data validation, and store data in an SQLite database. 4 | 5 | ![Build a CRUD App with FastAPI and SQLAlchemy](https://codevoweb.com/wp-content/uploads/2022/11/Build-a-CRUD-App-with-FastAPI-and-SQLAlchemy.png) 6 | 7 | ## Topics Covered 8 | 9 | - Run the SQLAlchemy FastAPI App Locally 10 | - Run the Frontend App Locally 11 | - Setup FastAPI and Run the HTTP Server 12 | - Designing the CRUD API 13 | - Setup SQLAlchemy with SQLite 14 | - Setup SQLAlchemy with PostgreSQL 15 | - Create Database Model with SQLAlchemy 16 | - Database Model for SQLite Database 17 | - Database Model for Postgres Database 18 | - Create Validation Schemas with Pydantic 19 | - Define the Path Operation Functions 20 | - Get All Records 21 | - Create a Record 22 | - Update a Record 23 | - Retrieve a Single Record 24 | - Delete a Single Record 25 | - Connect the API Router to the App 26 | 27 | Read the entire article here: [https://codevoweb.com/build-a-crud-app-with-fastapi-and-sqlalchemy](https://codevoweb.com/build-a-crud-app-with-fastapi-and-sqlalchemy) 28 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wpcodevo/fastapi_sqlalchemy/a04aa8b5d5cfdd9a2850344889f5e3275d387920/app/__init__.py -------------------------------------------------------------------------------- /app/config.py: -------------------------------------------------------------------------------- 1 | from pydantic_settings import BaseSettings 2 | from dotenv import load_dotenv 3 | 4 | load_dotenv() # Explicitly load the .env file 5 | 6 | 7 | class Settings(BaseSettings): 8 | POSTGRES_HOSTNAME: str 9 | POSTGRES_USER: str = "postgres" 10 | POSTGRES_PASSWORD: str = "password" 11 | POSTGRES_DB: str = "fastapi" 12 | DATABASE_PORT: int = 5432 13 | API_KEY: str 14 | 15 | class Config: 16 | env_file = ".env" 17 | 18 | 19 | settings = Settings() 20 | -------------------------------------------------------------------------------- /app/database.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import create_engine 2 | from sqlalchemy.ext.declarative import declarative_base 3 | from sqlalchemy.orm import sessionmaker 4 | from .config import settings 5 | from fastapi_utils.guid_type import setup_guids_postgresql 6 | 7 | 8 | 9 | POSTGRES_URL = ( 10 | f"postgresql://{settings.POSTGRES_USER}:{settings.POSTGRES_PASSWORD}@{settings.POSTGRES_HOSTNAME}:{settings.DATABASE_PORT}/{settings.POSTGRES_DB}" 11 | ) 12 | 13 | 14 | engine = create_engine( 15 | POSTGRES_URL, echo=True 16 | ) 17 | setup_guids_postgresql(engine) 18 | SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) 19 | 20 | Base = declarative_base() 21 | 22 | def get_db(): 23 | db = SessionLocal() 24 | try: 25 | yield db 26 | finally: 27 | db.close() 28 | 29 | -------------------------------------------------------------------------------- /app/main.py: -------------------------------------------------------------------------------- 1 | from app import models, note 2 | from fastapi import FastAPI, HTTPException, Depends 3 | from fastapi.middleware.cors import CORSMiddleware 4 | from .database import engine, get_db 5 | from dotenv import load_dotenv 6 | import os 7 | import requests 8 | from sqlalchemy.exc import OperationalError 9 | from sqlalchemy.orm import Session 10 | 11 | models.Base.metadata.create_all(bind=engine) 12 | 13 | load_dotenv() 14 | 15 | app = FastAPI() 16 | 17 | origins = [ 18 | "http://localhost:3000", 19 | ] 20 | 21 | app.add_middleware( 22 | CORSMiddleware, 23 | allow_origins=origins, 24 | allow_credentials=True, 25 | allow_methods=["*"], 26 | allow_headers=["*"], 27 | ) 28 | 29 | 30 | app.include_router(note.router, tags=['Notes'], prefix='/api/notes') 31 | 32 | 33 | @app.get("/api/healthchecker") 34 | def root(): 35 | return {"message": "Welcome to FastAPI with SQLAlchemy"} 36 | 37 | @app.get("/api/db-healthchecker") 38 | def db_healthchecker(db: Session = Depends(get_db)): 39 | try: 40 | # Attempt to execute a simple query to check database connectivity 41 | db.execute("SELECT 1") 42 | return {"message": "Database is healthy"} 43 | except OperationalError: 44 | raise HTTPException(status_code=500, detail="Database is not reachable") 45 | 46 | @app.get("/posts/{post_id}") 47 | async def get_post(post_id: int): 48 | try: 49 | #Make a GET request to the JSONPlaceholder API 50 | response = requests.get(f"https://jsonplaceholder.typicode.com/posts/{post_id}") 51 | #Check if the request was successful (status code 200) 52 | if response.status_code == 200: 53 | return response.json() 54 | else: 55 | raise HTTPException(status_code=response.status_code, detail="API call failed") 56 | except Exception as e: 57 | raise HTTPException(status_code=500, detail="Internal server error") 58 | 59 | 60 | @app.get('/crypto-price-ethereum') 61 | async def get_crypto_price(): 62 | try: 63 | api_key = os.getenv("api_key") 64 | if not api_key: 65 | raise HTTPException(status_code=500, detail="API key not configured") 66 | 67 | url = ( 68 | "https://api.coingecko.com/api/v3/simple/token_price/ethereum" 69 | "?contract_addresses=0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48" 70 | f"&vs_currencies=usd&x_cg_demo_api_key={api_key}" 71 | ) 72 | response = requests.get(url) 73 | 74 | if response.status_code == 200: 75 | return response.json() 76 | else: 77 | raise HTTPException(status_code=response.status_code, detail="API call failed") 78 | 79 | except Exception as e: 80 | raise HTTPException(status_code=500, detail=str(e)) 81 | -------------------------------------------------------------------------------- /app/models.py: -------------------------------------------------------------------------------- 1 | from .database import Base 2 | from sqlalchemy import TIMESTAMP, Column, String, Boolean 3 | from sqlalchemy.sql import func 4 | from fastapi_utils.guid_type import GUID, GUID_SERVER_DEFAULT_POSTGRESQL 5 | 6 | 7 | class Note(Base): 8 | __tablename__ = 'notes' 9 | id = Column(GUID, primary_key=True, 10 | server_default=GUID_SERVER_DEFAULT_POSTGRESQL) 11 | title = Column(String, nullable=False, unique=True) 12 | content = Column(String, nullable=False) 13 | category = Column(String, nullable=True) 14 | published = Column(Boolean, nullable=False, server_default='True') 15 | createdAt = Column(TIMESTAMP(timezone=True), 16 | nullable=False, server_default=func.now()) 17 | updatedAt = Column(TIMESTAMP(timezone=True), 18 | default=None, onupdate=func.now()) 19 | 20 | -------------------------------------------------------------------------------- /app/note.py: -------------------------------------------------------------------------------- 1 | from . import schemas, models 2 | from sqlalchemy.orm import Session 3 | from fastapi import Depends, HTTPException, status, APIRouter, Response 4 | from sqlalchemy.exc import IntegrityError 5 | from .database import get_db 6 | 7 | router = APIRouter() 8 | 9 | 10 | @router.get('/') 11 | def get_notes(db: Session = Depends(get_db), limit: int = 10, page: int = 1, search: str = ''): 12 | skip = (page - 1) * limit 13 | 14 | notes = db.query(models.Note).filter( 15 | models.Note.title.contains(search)).limit(limit).offset(skip).all() 16 | return {'status': 'success', 'results': len(notes), 'notes': notes} 17 | 18 | 19 | @router.post('/', status_code=status.HTTP_201_CREATED) 20 | def create_note(payload: schemas.NoteBaseSchema, db: Session = Depends(get_db)): 21 | new_note = models.Note( 22 | title=payload.title, 23 | content=payload.content, 24 | category=payload.category, 25 | published=payload.published 26 | ) 27 | 28 | db.add(new_note) 29 | try: 30 | db.commit() 31 | db.refresh(new_note) 32 | except IntegrityError: 33 | db.rollback() 34 | raise HTTPException(status_code=400, detail="Note with this title already exists.") 35 | 36 | return {"status": "success", "note": new_note} 37 | 38 | 39 | @router.patch('/{noteId}') 40 | def update_note(noteId: str, payload: schemas.NotePatchSchema, db: Session = Depends(get_db)): 41 | note_query = db.query(models.Note).filter(models.Note.id == noteId) 42 | db_note = note_query.first() 43 | 44 | if not db_note: 45 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, 46 | detail=f'No note with this id: {noteId} found') 47 | 48 | update_data = payload.model_dump(exclude_unset=True) 49 | 50 | if 'title' in update_data: 51 | existing_title = db.query(models.Note).filter( 52 | models.Note.title == update_data['title'], 53 | models.Note.id != noteId 54 | ).first() 55 | if existing_title: 56 | raise HTTPException(status_code=400, detail="Another note with this title already exists.") 57 | 58 | note_query.update(update_data, synchronize_session=False) 59 | db.commit() 60 | db.refresh(db_note) 61 | return {"status": "success", "note": db_note} 62 | 63 | 64 | @router.get('/{noteId}') 65 | def get_note(noteId: str, db: Session = Depends(get_db)): 66 | note = db.query(models.Note).filter(models.Note.id == noteId).first() 67 | if not note: 68 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, 69 | detail=f"No note with this id: {noteId} found") 70 | return {"status": "success", "note": note} 71 | 72 | 73 | @router.delete('/{noteId}') 74 | def delete_note(noteId: str, db: Session = Depends(get_db)): 75 | note_query = db.query(models.Note).filter(models.Note.id == noteId) 76 | note = note_query.first() 77 | if not note: 78 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, 79 | detail=f'No note with this id: {noteId} found') 80 | note_query.delete(synchronize_session=False) 81 | db.commit() 82 | return Response(status_code=status.HTTP_204_NO_CONTENT) 83 | -------------------------------------------------------------------------------- /app/schemas.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import List, Optional 3 | from pydantic import BaseModel 4 | 5 | class NoteBaseSchema(BaseModel): 6 | id: Optional[str] = None 7 | title: str 8 | content: str 9 | category: Optional[str] = None 10 | published: bool = False 11 | createdAt: Optional[datetime] = None 12 | updatedAt: Optional[datetime] = None 13 | 14 | class Config: 15 | orm_mode = True 16 | arbitrary_types_allowed = True 17 | 18 | # Schema for partial updates (PATCH request) 19 | class NotePatchSchema(NoteBaseSchema): 20 | title: Optional[str] = None 21 | content: Optional[str] = None 22 | category: Optional[str] = None 23 | published: Optional[bool] = None 24 | 25 | class ListNoteResponse(BaseModel): 26 | status: str 27 | results: int 28 | notes: List[NoteBaseSchema] 29 | -------------------------------------------------------------------------------- /example.env: -------------------------------------------------------------------------------- 1 | DATABASE_PORT=6500 2 | POSTGRES_PASSWORD=password123 3 | POSTGRES_USER=postgres 4 | POSTGRES_DB=fastapi 5 | POSTGRES_HOSTNAME=127.0.0.1 6 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | anyio #==3.6.2 2 | autopep8 #==1.7.1 3 | certifi #==2022.9.24 4 | charset-normalizer #==2.1.1 5 | click #==8.1.3 6 | colorama #==0.4.6 7 | dnspython #==2.2.1 8 | email-validator #==1.3.0 9 | fastapi #==0.85.1 10 | fastapi-utils #==0.2.1 11 | greenlet #==1.1.3.post0 12 | h11 #==0.14.0 13 | httptools #==0.5.0 14 | idna #==3.4 15 | itsdangerous #==2.1.2 16 | Jinja2 #==3.1.2 17 | MarkupSafe #==2.1.1 18 | orjson #==3.8.1 19 | psycopg2-binary #==2.9.5 20 | pycodestyle #==2.9.1 21 | pydantic #==1.10.2 22 | pydantic-settings ########### 23 | python-dotenv #==0.21.0 24 | python-multipart #==0.0.5 25 | PyYAML #==6.0 26 | requests #==2.28.1 27 | six #==1.16.0 28 | sniffio #==1.3.0 29 | SQLAlchemy<2.0 ## needs to be downgraded #==1.4.42 30 | starlette #==0.20.4 31 | tomli #==2.0.1 32 | typing_extensions #==4.4.0 33 | ujson #==5.5.0 34 | urllib3 #==1.26.12 35 | uvicorn #==0.18.3 36 | watchfiles #==0.18.0 37 | websockets #==10.4 38 | typing_inspect 39 | --------------------------------------------------------------------------------