Build Project: Todo App (Part 1)
1. Project Overview
Trong bài này và bài tiếp theo, chúng ta sẽ build một Todo App hoàn chỉnh:
- Part 1 (Bài này): Backend API với FastAPI
- Part 2 (Bài sau): Frontend với React
Tech Stack
| Layer | Technology |
|---|---|
| Backend | Python + FastAPI |
| Database | SQLite (dev) / PostgreSQL (prod) |
| ORM | SQLAlchemy |
| Frontend | React + TypeScript |
| Styling | Tailwind CSS |
Features
- ✅ CRUD operations cho todos
- ✅ Categories/Tags
- ✅ Due dates
- ✅ Priority levels
- ✅ Search & Filter
- ✅ Responsive UI
2. Project Setup
2.1 Khởi tạo Project
Build Process
Sử dụng Agent Mode
Mở Copilot Agent và paste prompt sau:
Text
1Create a FastAPI project structure for a Todo API:2 3Structure:4todo-api/5├── app/6│ ├── __init__.py7│ ├── main.py # FastAPI app entry8│ ├── config.py # Configuration9│ ├── database.py # Database connection10│ ├── models/11│ │ ├── __init__.py12│ │ └── todo.py # SQLAlchemy models13│ ├── schemas/14│ │ ├── __init__.py15│ │ └── todo.py # Pydantic schemas16│ ├── routers/17│ │ ├── __init__.py18│ │ └── todos.py # API routes19│ └── services/20│ ├── __init__.py21│ └── todo_service.py22├── tests/23│ ├── __init__.py24│ ├── conftest.py25│ └── test_todos.py26├── requirements.txt27└── README.md28 29Requirements:30- Use SQLAlchemy with SQLite31- Use Pydantic for validation32- Include CORS middleware33- Add proper error handling3. Database Models
3.1 Todo Model
Tạo file app/models/todo.py:
Python
1# Describe what you need with a comment, let Copilot generate23# SQLAlchemy Todo model with:4# - id: primary key, auto-increment5# - title: string, required, max 200 chars6# - description: text, optional7# - completed: boolean, default False8# - priority: enum (low, medium, high), default medium9# - due_date: datetime, optional10# - category: string, optional11# - created_at: datetime, auto-set12# - updated_at: datetime, auto-updateCopilot generates:
Python
1from datetime import datetime2from enum import Enum as PyEnum3from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, Enum4from sqlalchemy.sql import func5from app.database import Base67class Priority(str, PyEnum):8 LOW = "low"9 MEDIUM = "medium"10 HIGH = "high"1112class Todo(Base):13 __tablename__ = "todos"14 15 id = Column(Integer, primary_key=True, index=True, autoincrement=True)16 title = Column(String(200), nullable=False)17 description = Column(Text, nullable=True)18 completed = Column(Boolean, default=False)19 priority = Column(Enum(Priority), default=Priority.MEDIUM)20 due_date = Column(DateTime, nullable=True)21 category = Column(String(50), nullable=True)22 created_at = Column(DateTime, server_default=func.now())23 updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())24 25 def __repr__(self):26 return f"<Todo {self.id}: {self.title}>"3.2 Database Connection
File app/database.py:
Python
1# SQLAlchemy database connection for SQLite2# Include: engine, SessionLocal, Base, get_db dependencyCopilot generates:
Python
1from sqlalchemy import create_engine2from sqlalchemy.orm import sessionmaker, declarative_base3from app.config import settings45SQLALCHEMY_DATABASE_URL = settings.DATABASE_URL67engine = create_engine(8 SQLALCHEMY_DATABASE_URL,9 connect_args={"check_same_thread": False} # SQLite specific10)1112SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)1314Base = declarative_base()1516# Dependency17def get_db():18 db = SessionLocal()19 try:20 yield db21 finally:22 db.close()4. Pydantic Schemas
4.1 Request/Response Schemas
File app/schemas/todo.py:
Python
1# Pydantic schemas for Todo API:2# - TodoBase: shared fields3# - TodoCreate: for creating4# - TodoUpdate: for updating (all optional)5# - TodoResponse: response with id and timestamps6# - TodoListResponse: paginated listCopilot generates:
Python
1from datetime import datetime2from typing import Optional, List3from pydantic import BaseModel, Field4from app.models.todo import Priority56class TodoBase(BaseModel):7 title: str = Field(..., min_length=1, max_length=200)8 description: Optional[str] = None9 priority: Priority = Priority.MEDIUM10 due_date: Optional[datetime] = None11 category: Optional[str] = Field(None, max_length=50)1213class TodoCreate(TodoBase):14 pass1516class TodoUpdate(BaseModel):17 title: Optional[str] = Field(None, min_length=1, max_length=200)18 description: Optional[str] = None19 completed: Optional[bool] = None20 priority: Optional[Priority] = None21 due_date: Optional[datetime] = None22 category: Optional[str] = Field(None, max_length=50)2324class TodoResponse(TodoBase):25 id: int26 completed: bool27 created_at: datetime28 updated_at: datetime29 30 class Config:31 from_attributes = True3233class TodoListResponse(BaseModel):34 items: List[TodoResponse]35 total: int36 page: int37 page_size: int38 has_next: bool5. Service Layer
5.1 Todo Service
File app/services/todo_service.py:
Python
1# TodoService class with CRUD operations:2# - get_todos(filters, pagination) -> list3# - get_todo_by_id(id) -> todo or None4# - create_todo(data) -> todo5# - update_todo(id, data) -> todo or None6# - delete_todo(id) -> bool7# - toggle_completed(id) -> todo or NoneCopilot generates:
Python
1from typing import Optional, List2from sqlalchemy.orm import Session3from sqlalchemy import or_4from app.models.todo import Todo, Priority5from app.schemas.todo import TodoCreate, TodoUpdate67class TodoService:8 def __init__(self, db: Session):9 self.db = db10 11 def get_todos(12 self,13 skip: int = 0,14 limit: int = 10,15 completed: Optional[bool] = None,16 priority: Optional[Priority] = None,17 category: Optional[str] = None,18 search: Optional[str] = None,19 ) -> tuple[List[Todo], int]:20 query = self.db.query(Todo)21 22 # Apply filters23 if completed is not None:24 query = query.filter(Todo.completed == completed)25 if priority:26 query = query.filter(Todo.priority == priority)27 if category:28 query = query.filter(Todo.category == category)29 if search:30 query = query.filter(31 or_(32 Todo.title.ilike(f"%{search}%"),33 Todo.description.ilike(f"%{search}%")34 )35 )36 37 # Get total count38 total = query.count()39 40 # Apply pagination41 todos = query.order_by(Todo.created_at.desc())\42 .offset(skip)\43 .limit(limit)\44 .all()45 46 return todos, total47 48 def get_todo_by_id(self, todo_id: int) -> Optional[Todo]:49 return self.db.query(Todo).filter(Todo.id == todo_id).first()50 51 def create_todo(self, todo_data: TodoCreate) -> Todo:52 todo = Todo(**todo_data.model_dump())53 self.db.add(todo)54 self.db.commit()55 self.db.refresh(todo)56 return todo57 58 def update_todo(self, todo_id: int, todo_data: TodoUpdate) -> Optional[Todo]:59 todo = self.get_todo_by_id(todo_id)60 if not todo:61 return None62 63 update_data = todo_data.model_dump(exclude_unset=True)64 for field, value in update_data.items():65 setattr(todo, field, value)66 67 self.db.commit()68 self.db.refresh(todo)69 return todo70 71 def delete_todo(self, todo_id: int) -> bool:72 todo = self.get_todo_by_id(todo_id)73 if not todo:74 return False75 76 self.db.delete(todo)77 self.db.commit()78 return True79 80 def toggle_completed(self, todo_id: int) -> Optional[Todo]:81 todo = self.get_todo_by_id(todo_id)82 if not todo:83 return None84 85 todo.completed = not todo.completed86 self.db.commit()87 self.db.refresh(todo)88 return todo89 90 def get_categories(self) -> List[str]:91 """Get all unique categories"""92 result = self.db.query(Todo.category)\93 .filter(Todo.category.isnot(None))\94 .distinct()\95 .all()96 return [r[0] for r in result]6. API Routes
6.1 Todo Router
File app/routers/todos.py:
Python
1# FastAPI router for todos:2# - GET /todos - list with filters and pagination3# - GET /todos/{id} - get single todo4# - POST /todos - create todo5# - PUT /todos/{id} - update todo6# - DELETE /todos/{id} - delete todo7# - POST /todos/{id}/toggle - toggle completed8# - GET /todos/categories - get all categoriesCopilot generates:
Python
1from typing import Optional2from fastapi import APIRouter, Depends, HTTPException, Query3from sqlalchemy.orm import Session4from app.database import get_db5from app.models.todo import Priority6from app.schemas.todo import (7 TodoCreate, 8 TodoUpdate, 9 TodoResponse, 10 TodoListResponse11)12from app.services.todo_service import TodoService1314router = APIRouter(prefix="/todos", tags=["todos"])1516def get_service(db: Session = Depends(get_db)) -> TodoService:17 return TodoService(db)1819@router.get("", response_model=TodoListResponse)20def list_todos(21 page: int = Query(1, ge=1),22 page_size: int = Query(10, ge=1, le=100),23 completed: Optional[bool] = None,24 priority: Optional[Priority] = None,25 category: Optional[str] = None,26 search: Optional[str] = None,27 service: TodoService = Depends(get_service)28):29 skip = (page - 1) * page_size30 todos, total = service.get_todos(31 skip=skip,32 limit=page_size,33 completed=completed,34 priority=priority,35 category=category,36 search=search37 )38 39 return TodoListResponse(40 items=todos,41 total=total,42 page=page,43 page_size=page_size,44 has_next=(skip + page_size) < total45 )4647@router.get("/categories")48def get_categories(service: TodoService = Depends(get_service)):49 return service.get_categories()5051@router.get("/{todo_id}", response_model=TodoResponse)52def get_todo(todo_id: int, service: TodoService = Depends(get_service)):53 todo = service.get_todo_by_id(todo_id)54 if not todo:55 raise HTTPException(status_code=404, detail="Todo not found")56 return todo5758@router.post("", response_model=TodoResponse, status_code=201)59def create_todo(60 todo_data: TodoCreate,61 service: TodoService = Depends(get_service)62):63 return service.create_todo(todo_data)6465@router.put("/{todo_id}", response_model=TodoResponse)66def update_todo(67 todo_id: int,68 todo_data: TodoUpdate,69 service: TodoService = Depends(get_service)70):71 todo = service.update_todo(todo_id, todo_data)72 if not todo:73 raise HTTPException(status_code=404, detail="Todo not found")74 return todo7576@router.delete("/{todo_id}", status_code=204)77def delete_todo(todo_id: int, service: TodoService = Depends(get_service)):78 if not service.delete_todo(todo_id):79 raise HTTPException(status_code=404, detail="Todo not found")80 return None8182@router.post("/{todo_id}/toggle", response_model=TodoResponse)83def toggle_todo(todo_id: int, service: TodoService = Depends(get_service)):84 todo = service.toggle_completed(todo_id)85 if not todo:86 raise HTTPException(status_code=404, detail="Todo not found")87 return todo7. Main Application
7.1 App Entry Point
File app/main.py:
Python
1# FastAPI main app with:2# - CORS middleware3# - Include todo router4# - Create tables on startup5# - Health check endpointCopilot generates:
Python
1from fastapi import FastAPI2from fastapi.middleware.cors import CORSMiddleware3from app.database import engine, Base4from app.routers import todos56# Create tables7Base.metadata.create_all(bind=engine)89app = FastAPI(10 title="Todo API",11 description="A simple Todo API built with FastAPI",12 version="1.0.0"13)1415# CORS16app.add_middleware(17 CORSMiddleware,18 allow_origins=["*"], # Configure for production19 allow_credentials=True,20 allow_methods=["*"],21 allow_headers=["*"],22)2324# Include routers25app.include_router(todos.router, prefix="/api")2627@app.get("/")28def root():29 return {"message": "Welcome to Todo API"}3031@app.get("/health")32def health_check():33 return {"status": "healthy"}7.2 Configuration
File app/config.py:
Python
1from pydantic_settings import BaseSettings23class Settings(BaseSettings):4 DATABASE_URL: str = "sqlite:///./todos.db"5 API_PREFIX: str = "/api"6 DEBUG: bool = True7 8 class Config:9 env_file = ".env"1011settings = Settings()8. Testing
8.1 Test Configuration
File tests/conftest.py:
Python
1# Pytest fixtures for:2# - Test database (in-memory SQLite)3# - Test client4# - Sample todo dataCopilot generates:
Python
1import pytest2from fastapi.testclient import TestClient3from sqlalchemy import create_engine4from sqlalchemy.orm import sessionmaker5from app.main import app6from app.database import Base, get_db78SQLALCHEMY_DATABASE_URL = "sqlite:///:memory:"910engine = create_engine(11 SQLALCHEMY_DATABASE_URL,12 connect_args={"check_same_thread": False}13)14TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)1516@pytest.fixture(scope="function")17def db_session():18 Base.metadata.create_all(bind=engine)19 session = TestingSessionLocal()20 try:21 yield session22 finally:23 session.close()24 Base.metadata.drop_all(bind=engine)2526@pytest.fixture(scope="function")27def client(db_session):28 def override_get_db():29 try:30 yield db_session31 finally:32 pass33 34 app.dependency_overrides[get_db] = override_get_db35 with TestClient(app) as c:36 yield c37 app.dependency_overrides.clear()3839@pytest.fixture40def sample_todo():41 return {42 "title": "Test Todo",43 "description": "This is a test",44 "priority": "medium",45 "category": "work"46 }8.2 API Tests
File tests/test_todos.py:
Python
1# Test cases for Todo API:2# - Create todo3# - Get todo4# - List todos with filters5# - Update todo6# - Delete todo7# - Toggle completedCopilot generates:
Python
1import pytest23class TestTodoAPI:4 def test_create_todo(self, client, sample_todo):5 response = client.post("/api/todos", json=sample_todo)6 7 assert response.status_code == 2018 data = response.json()9 assert data["title"] == sample_todo["title"]10 assert data["completed"] == False11 assert "id" in data12 13 def test_get_todo(self, client, sample_todo):14 # Create first15 create_response = client.post("/api/todos", json=sample_todo)16 todo_id = create_response.json()["id"]17 18 # Get19 response = client.get(f"/api/todos/{todo_id}")20 21 assert response.status_code == 20022 assert response.json()["id"] == todo_id23 24 def test_get_todo_not_found(self, client):25 response = client.get("/api/todos/99999")26 assert response.status_code == 40427 28 def test_list_todos(self, client, sample_todo):29 # Create multiple30 client.post("/api/todos", json=sample_todo)31 client.post("/api/todos", json={**sample_todo, "title": "Another"})32 33 response = client.get("/api/todos")34 35 assert response.status_code == 20036 data = response.json()37 assert data["total"] == 238 assert len(data["items"]) == 239 40 def test_list_todos_with_filter(self, client, sample_todo):41 client.post("/api/todos", json={**sample_todo, "category": "work"})42 client.post("/api/todos", json={**sample_todo, "category": "personal"})43 44 response = client.get("/api/todos?category=work")45 46 assert response.status_code == 20047 assert response.json()["total"] == 148 49 def test_update_todo(self, client, sample_todo):50 create_response = client.post("/api/todos", json=sample_todo)51 todo_id = create_response.json()["id"]52 53 response = client.put(54 f"/api/todos/{todo_id}",55 json={"title": "Updated Title"}56 )57 58 assert response.status_code == 20059 assert response.json()["title"] == "Updated Title"60 61 def test_delete_todo(self, client, sample_todo):62 create_response = client.post("/api/todos", json=sample_todo)63 todo_id = create_response.json()["id"]64 65 response = client.delete(f"/api/todos/{todo_id}")66 67 assert response.status_code == 20468 69 # Verify deleted70 get_response = client.get(f"/api/todos/{todo_id}")71 assert get_response.status_code == 40472 73 def test_toggle_completed(self, client, sample_todo):74 create_response = client.post("/api/todos", json=sample_todo)75 todo_id = create_response.json()["id"]76 assert create_response.json()["completed"] == False77 78 # Toggle79 response = client.post(f"/api/todos/{todo_id}/toggle")80 81 assert response.status_code == 20082 assert response.json()["completed"] == True83 84 # Toggle again85 response = client.post(f"/api/todos/{todo_id}/toggle")86 assert response.json()["completed"] == False87 88 def test_search_todos(self, client):89 client.post("/api/todos", json={"title": "Buy groceries"})90 client.post("/api/todos", json={"title": "Call mom"})91 client.post("/api/todos", json={"title": "Buy new phone"})92 93 response = client.get("/api/todos?search=buy")94 95 assert response.status_code == 20096 assert response.json()["total"] == 29. Running the Project
9.1 Install Dependencies
Bash
1pip install fastapi uvicorn sqlalchemy pydantic-settings pytest httpx9.2 Run Server
Bash
1uvicorn app.main:app --reload9.3 Run Tests
Bash
1pytest -v9.4 Test API
Bash
1# Create todo2curl -X POST http://localhost:8000/api/todos \3 -H "Content-Type: application/json" \4 -d '{"title": "Learn Copilot", "priority": "high"}'5 6# List todos7curl http://localhost:8000/api/todos8 9# Toggle completed10curl -X POST http://localhost:8000/api/todos/1/toggle10. API Documentation
FastAPI tự động generate docs:
- Swagger UI: http://localhost:8000/docs
- ReDoc: http://localhost:8000/redoc
Recap Part 1
Chúng ta đã build:
| Component | Status |
|---|---|
| Project Structure | ✅ |
| Database Models | ✅ |
| Pydantic Schemas | ✅ |
| Service Layer | ✅ |
| API Routes | ✅ |
| Tests | ✅ |
| Documentation | ✅ |
Tiếp theo
Bài tiếp theo: Build Project: Todo App (Part 2) - xây dựng Frontend React với TypeScript và kết nối với Backend API!
