Thực hành
45 phút
Bài 10/12

Build Project: Todo App (Part 1)

Xây dựng ứng dụng Todo App từ đầu đến cuối với Copilot - Backend API

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

LayerTechnology
BackendPython + FastAPI
DatabaseSQLite (dev) / PostgreSQL (prod)
ORMSQLAlchemy
FrontendReact + TypeScript
StylingTailwind 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__.py
7│ ├── main.py # FastAPI app entry
8│ ├── config.py # Configuration
9│ ├── database.py # Database connection
10│ ├── models/
11│ │ ├── __init__.py
12│ │ └── todo.py # SQLAlchemy models
13│ ├── schemas/
14│ │ ├── __init__.py
15│ │ └── todo.py # Pydantic schemas
16│ ├── routers/
17│ │ ├── __init__.py
18│ │ └── todos.py # API routes
19│ └── services/
20│ ├── __init__.py
21│ └── todo_service.py
22├── tests/
23│ ├── __init__.py
24│ ├── conftest.py
25│ └── test_todos.py
26├── requirements.txt
27└── README.md
28
29Requirements:
30- Use SQLAlchemy with SQLite
31- Use Pydantic for validation
32- Include CORS middleware
33- Add proper error handling

3. Database Models

3.1 Todo Model

Tạo file app/models/todo.py:

Python
1# Describe what you need with a comment, let Copilot generate
2
3# SQLAlchemy Todo model with:
4# - id: primary key, auto-increment
5# - title: string, required, max 200 chars
6# - description: text, optional
7# - completed: boolean, default False
8# - priority: enum (low, medium, high), default medium
9# - due_date: datetime, optional
10# - category: string, optional
11# - created_at: datetime, auto-set
12# - updated_at: datetime, auto-update

Copilot generates:

Python
1from datetime import datetime
2from enum import Enum as PyEnum
3from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, Enum
4from sqlalchemy.sql import func
5from app.database import Base
6
7class Priority(str, PyEnum):
8 LOW = "low"
9 MEDIUM = "medium"
10 HIGH = "high"
11
12class 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 SQLite
2# Include: engine, SessionLocal, Base, get_db dependency

Copilot generates:

Python
1from sqlalchemy import create_engine
2from sqlalchemy.orm import sessionmaker, declarative_base
3from app.config import settings
4
5SQLALCHEMY_DATABASE_URL = settings.DATABASE_URL
6
7engine = create_engine(
8 SQLALCHEMY_DATABASE_URL,
9 connect_args={"check_same_thread": False} # SQLite specific
10)
11
12SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
13
14Base = declarative_base()
15
16# Dependency
17def get_db():
18 db = SessionLocal()
19 try:
20 yield db
21 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 fields
3# - TodoCreate: for creating
4# - TodoUpdate: for updating (all optional)
5# - TodoResponse: response with id and timestamps
6# - TodoListResponse: paginated list

Copilot generates:

Python
1from datetime import datetime
2from typing import Optional, List
3from pydantic import BaseModel, Field
4from app.models.todo import Priority
5
6class TodoBase(BaseModel):
7 title: str = Field(..., min_length=1, max_length=200)
8 description: Optional[str] = None
9 priority: Priority = Priority.MEDIUM
10 due_date: Optional[datetime] = None
11 category: Optional[str] = Field(None, max_length=50)
12
13class TodoCreate(TodoBase):
14 pass
15
16class TodoUpdate(BaseModel):
17 title: Optional[str] = Field(None, min_length=1, max_length=200)
18 description: Optional[str] = None
19 completed: Optional[bool] = None
20 priority: Optional[Priority] = None
21 due_date: Optional[datetime] = None
22 category: Optional[str] = Field(None, max_length=50)
23
24class TodoResponse(TodoBase):
25 id: int
26 completed: bool
27 created_at: datetime
28 updated_at: datetime
29
30 class Config:
31 from_attributes = True
32
33class TodoListResponse(BaseModel):
34 items: List[TodoResponse]
35 total: int
36 page: int
37 page_size: int
38 has_next: bool

5. Service Layer

5.1 Todo Service

File app/services/todo_service.py:

Python
1# TodoService class with CRUD operations:
2# - get_todos(filters, pagination) -> list
3# - get_todo_by_id(id) -> todo or None
4# - create_todo(data) -> todo
5# - update_todo(id, data) -> todo or None
6# - delete_todo(id) -> bool
7# - toggle_completed(id) -> todo or None

Copilot generates:

Python
1from typing import Optional, List
2from sqlalchemy.orm import Session
3from sqlalchemy import or_
4from app.models.todo import Todo, Priority
5from app.schemas.todo import TodoCreate, TodoUpdate
6
7class TodoService:
8 def __init__(self, db: Session):
9 self.db = db
10
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 filters
23 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 count
38 total = query.count()
39
40 # Apply pagination
41 todos = query.order_by(Todo.created_at.desc())\
42 .offset(skip)\
43 .limit(limit)\
44 .all()
45
46 return todos, total
47
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 todo
57
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 None
62
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 todo
70
71 def delete_todo(self, todo_id: int) -> bool:
72 todo = self.get_todo_by_id(todo_id)
73 if not todo:
74 return False
75
76 self.db.delete(todo)
77 self.db.commit()
78 return True
79
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 None
84
85 todo.completed = not todo.completed
86 self.db.commit()
87 self.db.refresh(todo)
88 return todo
89
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 pagination
3# - GET /todos/{id} - get single todo
4# - POST /todos - create todo
5# - PUT /todos/{id} - update todo
6# - DELETE /todos/{id} - delete todo
7# - POST /todos/{id}/toggle - toggle completed
8# - GET /todos/categories - get all categories

Copilot generates:

Python
1from typing import Optional
2from fastapi import APIRouter, Depends, HTTPException, Query
3from sqlalchemy.orm import Session
4from app.database import get_db
5from app.models.todo import Priority
6from app.schemas.todo import (
7 TodoCreate,
8 TodoUpdate,
9 TodoResponse,
10 TodoListResponse
11)
12from app.services.todo_service import TodoService
13
14router = APIRouter(prefix="/todos", tags=["todos"])
15
16def get_service(db: Session = Depends(get_db)) -> TodoService:
17 return TodoService(db)
18
19@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_size
30 todos, total = service.get_todos(
31 skip=skip,
32 limit=page_size,
33 completed=completed,
34 priority=priority,
35 category=category,
36 search=search
37 )
38
39 return TodoListResponse(
40 items=todos,
41 total=total,
42 page=page,
43 page_size=page_size,
44 has_next=(skip + page_size) < total
45 )
46
47@router.get("/categories")
48def get_categories(service: TodoService = Depends(get_service)):
49 return service.get_categories()
50
51@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 todo
57
58@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)
64
65@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 todo
75
76@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 None
81
82@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 todo

7. Main Application

7.1 App Entry Point

File app/main.py:

Python
1# FastAPI main app with:
2# - CORS middleware
3# - Include todo router
4# - Create tables on startup
5# - Health check endpoint

Copilot generates:

Python
1from fastapi import FastAPI
2from fastapi.middleware.cors import CORSMiddleware
3from app.database import engine, Base
4from app.routers import todos
5
6# Create tables
7Base.metadata.create_all(bind=engine)
8
9app = FastAPI(
10 title="Todo API",
11 description="A simple Todo API built with FastAPI",
12 version="1.0.0"
13)
14
15# CORS
16app.add_middleware(
17 CORSMiddleware,
18 allow_origins=["*"], # Configure for production
19 allow_credentials=True,
20 allow_methods=["*"],
21 allow_headers=["*"],
22)
23
24# Include routers
25app.include_router(todos.router, prefix="/api")
26
27@app.get("/")
28def root():
29 return {"message": "Welcome to Todo API"}
30
31@app.get("/health")
32def health_check():
33 return {"status": "healthy"}

7.2 Configuration

File app/config.py:

Python
1from pydantic_settings import BaseSettings
2
3class Settings(BaseSettings):
4 DATABASE_URL: str = "sqlite:///./todos.db"
5 API_PREFIX: str = "/api"
6 DEBUG: bool = True
7
8 class Config:
9 env_file = ".env"
10
11settings = Settings()

8. Testing

8.1 Test Configuration

File tests/conftest.py:

Python
1# Pytest fixtures for:
2# - Test database (in-memory SQLite)
3# - Test client
4# - Sample todo data

Copilot generates:

Python
1import pytest
2from fastapi.testclient import TestClient
3from sqlalchemy import create_engine
4from sqlalchemy.orm import sessionmaker
5from app.main import app
6from app.database import Base, get_db
7
8SQLALCHEMY_DATABASE_URL = "sqlite:///:memory:"
9
10engine = create_engine(
11 SQLALCHEMY_DATABASE_URL,
12 connect_args={"check_same_thread": False}
13)
14TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
15
16@pytest.fixture(scope="function")
17def db_session():
18 Base.metadata.create_all(bind=engine)
19 session = TestingSessionLocal()
20 try:
21 yield session
22 finally:
23 session.close()
24 Base.metadata.drop_all(bind=engine)
25
26@pytest.fixture(scope="function")
27def client(db_session):
28 def override_get_db():
29 try:
30 yield db_session
31 finally:
32 pass
33
34 app.dependency_overrides[get_db] = override_get_db
35 with TestClient(app) as c:
36 yield c
37 app.dependency_overrides.clear()
38
39@pytest.fixture
40def 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 todo
3# - Get todo
4# - List todos with filters
5# - Update todo
6# - Delete todo
7# - Toggle completed

Copilot generates:

Python
1import pytest
2
3class 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 == 201
8 data = response.json()
9 assert data["title"] == sample_todo["title"]
10 assert data["completed"] == False
11 assert "id" in data
12
13 def test_get_todo(self, client, sample_todo):
14 # Create first
15 create_response = client.post("/api/todos", json=sample_todo)
16 todo_id = create_response.json()["id"]
17
18 # Get
19 response = client.get(f"/api/todos/{todo_id}")
20
21 assert response.status_code == 200
22 assert response.json()["id"] == todo_id
23
24 def test_get_todo_not_found(self, client):
25 response = client.get("/api/todos/99999")
26 assert response.status_code == 404
27
28 def test_list_todos(self, client, sample_todo):
29 # Create multiple
30 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 == 200
36 data = response.json()
37 assert data["total"] == 2
38 assert len(data["items"]) == 2
39
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 == 200
47 assert response.json()["total"] == 1
48
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 == 200
59 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 == 204
68
69 # Verify deleted
70 get_response = client.get(f"/api/todos/{todo_id}")
71 assert get_response.status_code == 404
72
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"] == False
77
78 # Toggle
79 response = client.post(f"/api/todos/{todo_id}/toggle")
80
81 assert response.status_code == 200
82 assert response.json()["completed"] == True
83
84 # Toggle again
85 response = client.post(f"/api/todos/{todo_id}/toggle")
86 assert response.json()["completed"] == False
87
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 == 200
96 assert response.json()["total"] == 2

9. Running the Project

9.1 Install Dependencies

Bash
1pip install fastapi uvicorn sqlalchemy pydantic-settings pytest httpx

9.2 Run Server

Bash
1uvicorn app.main:app --reload

9.3 Run Tests

Bash
1pytest -v

9.4 Test API

Bash
1# Create todo
2curl -X POST http://localhost:8000/api/todos \
3 -H "Content-Type: application/json" \
4 -d '{"title": "Learn Copilot", "priority": "high"}'
5
6# List todos
7curl http://localhost:8000/api/todos
8
9# Toggle completed
10curl -X POST http://localhost:8000/api/todos/1/toggle

10. API Documentation

FastAPI tự động generate docs:


Recap Part 1

Chúng ta đã build:

ComponentStatus
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!