Skip to main content
AIConnect
← Blog

Học Python 3 từng bước và dự án mẫu FastAPI (Step-by-step Python and FastAPI)

Từ cài đặt và những dòng code đầu tiên, qua cấu trúc dữ liệu, hàm, lỗi, kiểu dữ liệu, async và test, đến việc xây một API hoàn chỉnh bằng FastAPI — Mini Places API.

✦ View the full interactive version

Từ cài đặt và những dòng code đầu tiên, qua cấu trúc dữ liệu, hàm, lỗi, kiểu dữ liệu, async và test, đến việc xây một API hoàn chỉnh bằng FastAPI. Mỗi bước có code sao chép được, thử thách nhỏ và lỗi thường gặp.

Lộ trình (Cơ bản · Beginner)

Hai mươi bước chia thành bốn giai đoạn. Màu xanh dương và vàng là Python, màu xanh ngọc là FastAPI.

  1. Cú pháp cốt lõi — Bước 1 đến 4. Cài đặt, kiểu dữ liệu, rẽ nhánh và lặp, cấu trúc dữ liệu.
  2. Tổ chức code — Bước 5 đến 8. Hàm, module, lỗi và file, lớp.
  3. Chuẩn nghề — Bước 9 đến 12. Type hints, async, test, môi trường.
  4. Dự án FastAPI — 8 bước. API, dữ liệu, duyệt import, test.

Mỗi giai đoạn dựa trên giai đoạn trước. Đừng nhảy sang FastAPI khi chưa vững hàm, lỗi và type hints.

Vì sao Python?

Cú pháp gần với ngôn ngữ tự nhiên, thư viện chuẩn phong phú, dùng được cho web, dữ liệu, tự động hóa và AI.

Môi trường ảo

Mỗi dự án có một thư mục thư viện riêng (.venv) để các dự án không giẫm lên nhau.

Vì sao FastAPI?

Dùng type hints chuẩn của Python để tự kiểm tra dữ liệu và tự sinh tài liệu API tương tác.

Bản đồ kiểu dữ liệu (Core types at a glance)

KiểuVí dụCó thứ tựThay đổi đượcCho phép trùng
list[1, 2, 2]CóCóCó
tuple(1, 2)CóKhôngCó
dict{"a": 1}Theo thứ tự thêm vàoCóKhóa không trùng
set{1, 2}KhôngCóKhông
str, int, float, bool"abc", 3str cóKhông-

Mẹo nhớ: tuple và str “đóng băng” sau khi tạo, còn list, dict, set là “bảng trắng” có thể sửa. Khi hai biến cùng trỏ vào một list, sửa ở biến này thì biến kia cũng thay đổi.

Python Step Navigator (Tương tác · Interactive)

Mười hai bước. Bấm vào từng bước để xem giải thích, code, thử thách và lỗi thường gặp. Bản tương tác có đầy đủ code sao chép được cho từng bước; dưới đây là tóm tắt nội dung.

Bước 1: Cài đặt và chạy chương trình đầu tiên (Setup and first run)

Python là ngôn ngữ thông dịch: bạn viết file .py rồi cho trình thông dịch chạy. Hãy tạo môi trường ảo cho mỗi dự án để tách thư viện.

python3 --version
python3 -m venv .venv
source .venv/bin/activate        # macOS / Linux
.venv\Scripts\activate           # Windows
python -m pip install --upgrade pip

# hello.py
print("Xin chào, Python 3!")
  • Thử thách nhỏ: In ra tên bạn và ngày hôm nay (gợi ý: module datetime).
  • Lỗi thường gặp: Quên kích hoạt venv nên pip cài thư viện vào Python của hệ thống.

Bước 2: Biến và kiểu dữ liệu (Variables and types)

Biến là cái tên gắn với một giá trị. Python tự suy ra kiểu. Dùng f-string để ghép chuỗi: f"{name}".

name = "An"              # str
age = 30                 # int
height = 1.75            # float
is_learning = True       # bool
print(f"{name} - {age} tuổi - đang học: {is_learning}")
print(type(height))      # <class 'float'>
  • Thử thách nhỏ: Tính BMI từ chiều cao và cân nặng, in kết quả với 1 chữ số thập phân.
  • Lỗi thường gặp: Phép chia / luôn ra float. Dùng // khi cần chia lấy phần nguyên.

Bước 3: Rẽ nhánh và vòng lặp (Control flow)

Thụt lề (4 dấu cách) xác định khối lệnh. if chọn nhánh, for lặp qua tập giá trị, while lặp khi điều kiện còn đúng.

score = 72
if score >= 80:
    grade = "A"
elif score >= 60:
    grade = "B"
else:
    grade = "C"
print(grade)             # B

for i in range(3):
    print(i)             # 0 1 2

n = 3
while n > 0:
    print(n)
    n -= 1
  • Thử thách nhỏ: Viết FizzBuzz cho các số từ 1 đến 30.
  • Lỗi thường gặp: Thụt lề sai gây IndentationError. Đừng trộn tab với dấu cách.

Bước 4: Cấu trúc dữ liệu (Collections)

List (có thứ tự, sửa được), tuple (không sửa), dict (khóa và giá trị), set (không trùng). Comprehension tạo list ngắn gọn.

nums = [3, 1, 2]
nums.append(4)
nums.sort()
point = (10.8, 106.7)                  # tuple
place = {"name": "Công viên", "lat": 10.8}
tags = {"park", "walk", "park"}        # set, loại trùng
squares = [n * n for n in nums]
print(nums, place["name"], len(tags), squares)
  • Thử thách nhỏ: Đếm số lần xuất hiện của mỗi từ trong một câu bằng dict.
  • Lỗi thường gặp: Gán b = a cho list chỉ tạo thêm một tên cho cùng đối tượng. Dùng a.copy() để sao chép.

Bước 5: Hàm (Functions)

Hàm gom code dùng lại. Có tham số mặc định, tham số có tên, và giá trị trả về. Lambda là hàm ngắn dùng một lần.

def greet(name: str, polite: bool = True) -> str:
    prefix = "Xin chào" if polite else "Chào"
    return f"{prefix}, {name}!"

print(greet("An"))
print(greet("An", polite=False))

places = [{"name": "B", "rating": 4.1}, {"name": "A", "rating": 4.7}]
best = sorted(places, key=lambda p: p["rating"], reverse=True)
print(best[0]["name"])   # A
  • Thử thách nhỏ: Viết hàm trả về giá trị lớn nhất của một list mà không dùng max().
  • Lỗi thường gặp: Tham số mặc định dạng list như def f(x=[]) bị dùng chung giữa các lần gọi. Dùng None rồi tạo list bên trong.

Bước 6: Module và thư viện chuẩn (Modules and the standard library)

Mỗi file .py là một module. import giúp dùng code của file khác hoặc của thư viện chuẩn như json, pathlib, datetime.

import json
from datetime import datetime
from pathlib import Path

data = {"created": datetime.now().isoformat(), "items": [1, 2, 3]}
Path("data.json").write_text(json.dumps(data, indent=2), encoding="utf-8")
loaded = json.loads(Path("data.json").read_text(encoding="utf-8"))
print(loaded["items"])
  • Thử thách nhỏ: Ghi một danh sách địa điểm ra file JSON rồi đọc lại.
  • Lỗi thường gặp: Đặt tên file trùng thư viện chuẩn (ví dụ json.py) làm import bị hỏng.

Bước 7: Lỗi và file (Errors and files)

Dùng try/except để xử lý lỗi dự đoán được. Dùng with để file luôn được đóng.

def parse_age(text: str) -> int:
    try:
        age = int(text)
    except ValueError:
        raise ValueError(f"Tuổi không hợp lệ: {text!r}") from None
    if age < 0:
        raise ValueError("Tuổi không được âm")
    return age

try:
    parse_age("abc")
except ValueError as e:
    print(e)

with open("notes.txt", "w", encoding="utf-8") as f:
    f.write("dòng 1\n")
  • Thử thách nhỏ: Đọc một file có thể không tồn tại và bắt FileNotFoundError.
  • Lỗi thường gặp: Dùng except: trống sẽ nuốt mọi lỗi, kể cả lỗi bạn không lường trước.

Bước 8: Lớp và dataclass (Classes and dataclasses)

Lớp gom dữ liệu và hành vi. @dataclass tự sinh phần khởi tạo và so sánh, rất hợp với các đối tượng dữ liệu.

from dataclasses import dataclass

@dataclass
class Place:
    name: str
    lat: float
    lon: float

    def label(self) -> str:
        return f"{self.name} ({self.lat}, {self.lon})"

p = Place("Công viên", 10.8, 106.7)
print(p.label())
  • Thử thách nhỏ: Thêm phương thức is_north_of(other) so sánh vĩ độ hai địa điểm.
  • Lỗi thường gặp: Quên tham số self ở phương thức.

Bước 9: Type hints (Type hints)

Gợi ý kiểu giúp đọc code và giúp công cụ kiểm tra. Python không tự ép kiểu lúc chạy, nhưng Pydantic và FastAPI dùng chúng để kiểm tra dữ liệu.

def find(items: list[str], keyword: str) -> str | None:
    for item in items:
        if keyword in item:
            return item
    return None

print(find(["park", "cafe"], "caf"))   # cafe
print(find(["park"], "zoo"))           # None
  • Thử thách nhỏ: Thêm type hints cho tất cả hàm bạn đã viết.
  • Lỗi thường gặp: Tưởng type hints tự báo lỗi khi chạy. Việc kiểm tra lúc chạy cần Pydantic hoặc code của bạn.

Bước 10: Bất đồng bộ (Async and await)

Khi chờ I/O (mạng, đĩa), chương trình có thể làm việc khác. async def khai báo coroutine, await chờ kết quả. FastAPI dùng khái niệm này.

import asyncio

async def fetch(name: str, delay: float) -> str:
    await asyncio.sleep(delay)
    return f"xong {name}"

async def main():
    results = await asyncio.gather(fetch("a", 1), fetch("b", 1))
    print(results)       # mất khoảng 1 giây thay vì 2

asyncio.run(main())
  • Thử thách nhỏ: Chạy ba tác vụ sleep song song bằng asyncio.gather và đo thời gian.
  • Lỗi thường gặp: Gọi hàm chặn như time.sleep trong async def làm đứng cả vòng lặp sự kiện.

Bước 11: Kiểm thử với pytest (Testing with pytest)

Test là code kiểm tra code. Hàm bắt đầu bằng test_ và dùng assert. Chạy bằng pytest.

# test_math.py
def add(a, b):
    return a + b

def test_add():
    assert add(2, 3) == 5

def test_add_negative():
    assert add(-1, 1) == 0

# chạy:  python -m pytest
  • Thử thách nhỏ: Viết ba test cho hàm greet ở bước 5.
  • Lỗi thường gặp: Chỉ test trường hợp đúng. Hãy thêm cả đầu vào rỗng, âm và giá trị biên.

Bước 12: Môi trường và đóng gói (Environments and dependencies)

Ghi lại thư viện dự án cần để người khác (hoặc bạn ở máy khác) cài lại được. Có thể dùng pip hoặc công cụ uv.

# với pip
pip freeze > requirements.txt
pip install -r requirements.txt

# với uv (công cụ quản lý dự án và gói)
uv init places-api
cd places-api
uv add "fastapi[standard]"
  • Thử thách nhỏ: Tạo một dự án mới, cài một thư viện và ghi lại danh sách phụ thuộc.
  • Lỗi thường gặp: Commit cả thư mục .venv lên Git. Hãy thêm nó vào .gitignore.

Đoán kết quả (Predict the output)

Đọc code, đoán kết quả, rồi kiểm tra. Luyện đọc code là kỹ năng nền tảng. Phiên bản tương tác có 8 câu; đáp án:

  1. x = [1, 2, 3]; y = x; y.append(4); print(x) → [1, 2, 3, 4] — vì y = x không sao chép, cả hai tên cùng trỏ vào một list.
  2. print(10 // 3, 10 % 3, 2 ** 3) → 3 1 8 — // là chia lấy phần nguyên, % là phần dư, ** là lũy thừa.
  3. s = "python"; print(s[1:4], s[::-1]) → yth nohtyp — s[1:4] lấy chỉ số 1 đến 3; s[::-1] đảo ngược chuỗi.
  4. d = {"a": 1}; print(d.get("b", 0)) → 0 — get trả về giá trị mặc định khi khóa không tồn tại.
  5. def f(a, b=2): return a * b; print(f(3), f(3, 4)) → 6 12 — lần đầu dùng b mặc định 2, lần hai b là 4.
  6. nums = [1, 2, 3, 4]; print([n * n for n in nums if n % 2 == 0]) → [4, 16] — chỉ giữ số chẵn (2 và 4) rồi bình phương.
  7. print(bool([]), bool("0"), bool(0)) → False True False — list rỗng và số 0 là False; chuỗi không rỗng, kể cả "0", là True.
  8. t = (1, 2); t[0] = 9 → TypeError — tuple là bất biến nên không gán lại phần tử được.

Kế hoạch học (Study planner)

Một công cụ tương tác cho phép kéo số giờ mỗi tuần để ước tính thời gian hoàn thành. Với 12 bước Python và 8 bước FastAPI, tổng thời gian cần khoảng 84 giờ (52 giờ Python + 32 giờ FastAPI). Số giờ mỗi bước chỉ là ước tính tham khảo cho người mới, hãy tự điều chỉnh.

Dự án mẫu: Mini Places API (FastAPI)

API quản lý địa điểm: thêm, xem, tìm, xóa địa điểm, và một quy trình duyệt dữ liệu nhập vào (pending, approved hoặc rejected). Dữ liệu nhập chưa được duyệt thì không hiện ra API công khai.

Lưu ý: code trong trang được viết theo cách FastAPI, Pydantic v2 và SQLModel được mô tả trong tài liệu, nhưng chưa được chạy kiểm thử ở đây. Hãy chạy từng bước, đọc lỗi và đối chiếu với tài liệu chính thức nếu phiên bản thư viện của bạn khác.

F1: Chuẩn bị dự án (Project setup)

"fastapi[standard]" cài FastAPI cùng Uvicorn và công cụ dòng lệnh fastapi. Đặt trong dấu ngoặc kép để chạy được trên mọi terminal. SQLModel dùng cho cơ sở dữ liệu, pytest cho test.

mkdir places-api && cd places-api
python3 -m venv .venv
source .venv/bin/activate        # Windows: .venv\Scripts\activate
pip install "fastapi[standard]" sqlmodel pytest

F2: Hello FastAPI (First endpoint)

@app.get("/") là một decorator: gắn hàm bên dưới vào đường dẫn và phương thức HTTP. Lệnh fastapi dev đọc main.py, tìm đối tượng app và chạy server có tự nạp lại khi bạn sửa code. Mở http://127.0.0.1:8000/docs để thấy tài liệu tương tác tự sinh.

# main.py
from fastapi import FastAPI

app = FastAPI(title="Mini Places API")


@app.get("/")
def read_root():
    return {"message": "Hello, FastAPI"}
fastapi dev main.py

F3: Tham số đường dẫn và truy vấn (Path and query parameters)

Tham số trong {} là path parameter, tham số có giá trị mặc định là query parameter. FastAPI đọc type hint để tự ép kiểu và kiểm tra: gọi /places/abc sẽ nhận lỗi 422.

# main.py (thêm vào)
@app.get("/places/{place_id}")
def read_place(place_id: int, verbose: bool = False):
    return {"place_id": place_id, "verbose": verbose}

F4: Pydantic: kiểm tra dữ liệu (Request bodies)

Khai báo model kế thừa BaseModel. FastAPI đọc JSON từ body, kiểm tra theo model và trả 422 nếu sai. Field đặt ràng buộc như độ dài, khoảng giá trị. Ở bước này dữ liệu còn nằm trong bộ nhớ.

# main.py (thêm vào)
from pydantic import BaseModel, Field


class PlaceCreate(BaseModel):
    name: str = Field(min_length=1, max_length=120)
    latitude: float = Field(ge=-90, le=90)
    longitude: float = Field(ge=-180, le=180)


PLACES: dict[int, dict] = {}


@app.post("/places", status_code=201)
def create_place(data: PlaceCreate):
    place_id = len(PLACES) + 1
    PLACES[place_id] = {"id": place_id, **data.model_dump()}
    return PLACES[place_id]

F5: Cấu trúc package và cơ sở dữ liệu (Package layout and database)

Từ đây tách code thành package. Dùng SQLModel với SQLite: một lớp vừa là bảng vừa là model Pydantic. Tách lớp đọc (PlaceRead) và lớp tạo (PlaceCreate) để không lộ hay nhận sai trường. Hãy tạo thêm hai file __init__.py rỗng trong app/ và app/routers/.

places-api/
├── app/
│   ├── __init__.py
│   ├── main.py
│   ├── db.py
│   ├── models.py
│   └── routers/
│       ├── __init__.py
│       ├── places.py
│       └── imports.py
└── tests/
    └── test_places.py
# app/models.py
from enum import Enum

from sqlmodel import Field, SQLModel


class ImportStatus(str, Enum):
    pending = "pending"
    approved = "approved"
    rejected = "rejected"


class PlaceBase(SQLModel):
    name: str = Field(index=True, min_length=1, max_length=120)
    address: str | None = None
    category: str = "other"
    latitude: float = Field(ge=-90, le=90)
    longitude: float = Field(ge=-180, le=180)


class Place(PlaceBase, table=True):
    id: int | None = Field(default=None, primary_key=True)


class PlaceCreate(PlaceBase):
    pass


class PlaceRead(PlaceBase):
    id: int


class ImportCreate(PlaceBase):
    google_place_id: str


class ImportCandidate(ImportCreate, table=True):
    id: int | None = Field(default=None, primary_key=True)
    google_place_id: str = Field(index=True, unique=True)
    status: ImportStatus = ImportStatus.pending


class ImportRead(ImportCreate):
    id: int
    status: ImportStatus
# app/db.py
from typing import Annotated

from fastapi import Depends
from sqlmodel import Session, SQLModel, create_engine

engine = create_engine(
    "sqlite:///places.db", connect_args={"check_same_thread": False}
)


def create_db_and_tables() -> None:
    SQLModel.metadata.create_all(engine)


def get_session():
    with Session(engine) as session:
        yield session


SessionDep = Annotated[Session, Depends(get_session)]

F6: Router và CRUD (Routers and CRUD)

APIRouter gom các endpoint cùng chủ đề. response_model quyết định dữ liệu trả ra. Depends (qua SessionDep) cấp session cho mỗi request. Thiếu bản ghi thì ném HTTPException 404.

# app/routers/places.py
from fastapi import APIRouter, HTTPException, Query, status
from sqlmodel import select

from ..db import SessionDep
from ..models import Place, PlaceCreate, PlaceRead

router = APIRouter(prefix="/places", tags=["places"])


@router.post("", response_model=PlaceRead, status_code=status.HTTP_201_CREATED)
def create_place(data: PlaceCreate, session: SessionDep):
    place = Place.model_validate(data)
    session.add(place)
    session.commit()
    session.refresh(place)
    return place


@router.get("", response_model=list[PlaceRead])
def list_places(
    session: SessionDep,
    q: str | None = None,
    offset: int = 0,
    limit: int = Query(default=20, le=100),
):
    stmt = select(Place)
    if q:
        stmt = stmt.where(Place.name.contains(q))
    return session.exec(stmt.offset(offset).limit(limit)).all()


@router.get("/{place_id}", response_model=PlaceRead)
def get_place(place_id: int, session: SessionDep):
    place = session.get(Place, place_id)
    if place is None:
        raise HTTPException(status_code=404, detail="Place not found")
    return place


@router.delete("/{place_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_place(place_id: int, session: SessionDep):
    place = session.get(Place, place_id)
    if place is None:
        raise HTTPException(status_code=404, detail="Place not found")
    session.delete(place)
    session.commit()
# app/main.py
from contextlib import asynccontextmanager

from fastapi import FastAPI

from .db import create_db_and_tables
from .routers import imports, places


@asynccontextmanager
async def lifespan(app: FastAPI):
    create_db_and_tables()
    yield


app = FastAPI(title="Mini Places API", lifespan=lifespan)
app.include_router(places.router)
app.include_router(imports.router)


@app.get("/health")
def health():
    return {"status": "ok"}
fastapi dev

F7: Quy trình duyệt import (Import approval workflow)

Quy tắc nghiệp vụ: dữ liệu nhập vào nằm ở trạng thái pending và không ghi thẳng vào bảng địa điểm công khai. Chỉ khi admin approve mới tạo Place. Duyệt lần hai trả 409. Lưu ý: bài mẫu chưa có xác thực cho /admin.

# app/routers/imports.py
from fastapi import APIRouter, HTTPException, status
from sqlmodel import select

from ..db import SessionDep
from ..models import (
    ImportCandidate,
    ImportCreate,
    ImportRead,
    ImportStatus,
    Place,
    PlaceRead,
)

router = APIRouter(prefix="/admin/imports", tags=["admin imports"])


def get_pending(session, candidate_id: int) -> ImportCandidate:
    candidate = session.get(ImportCandidate, candidate_id)
    if candidate is None:
        raise HTTPException(status_code=404, detail="Candidate not found")
    if candidate.status != ImportStatus.pending:
        raise HTTPException(status_code=409, detail="Candidate already reviewed")
    return candidate


@router.post("", response_model=ImportRead, status_code=status.HTTP_201_CREATED)
def create_candidate(data: ImportCreate, session: SessionDep):
    stmt = select(ImportCandidate).where(
        ImportCandidate.google_place_id == data.google_place_id
    )
    if session.exec(stmt).first() is not None:
        raise HTTPException(status_code=409, detail="Already imported")
    candidate = ImportCandidate.model_validate(data)
    session.add(candidate)
    session.commit()
    session.refresh(candidate)
    return candidate


@router.post("/{candidate_id}/approve", response_model=PlaceRead)
def approve_candidate(candidate_id: int, session: SessionDep):
    candidate = get_pending(session, candidate_id)
    place = Place(
        name=candidate.name,
        address=candidate.address,
        category=candidate.category,
        latitude=candidate.latitude,
        longitude=candidate.longitude,
    )
    candidate.status = ImportStatus.approved
    session.add(place)
    session.add(candidate)
    session.commit()
    session.refresh(place)
    return place


@router.post("/{candidate_id}/reject", response_model=ImportRead)
def reject_candidate(candidate_id: int, session: SessionDep):
    candidate = get_pending(session, candidate_id)
    candidate.status = ImportStatus.rejected
    session.add(candidate)
    session.commit()
    session.refresh(candidate)
    return candidate

F8: Kiểm thử API (Testing the API)

TestClient gọi API như một client thật mà không cần chạy server. Ghi đè get_session bằng cơ sở dữ liệu SQLite trong bộ nhớ để mỗi test chạy trên dữ liệu sạch. Chạy bằng python -m pytest để thư mục dự án nằm trong đường dẫn import.

# tests/test_places.py
import pytest
from fastapi.testclient import TestClient
from sqlmodel import Session, SQLModel, create_engine
from sqlmodel.pool import StaticPool

from app.db import get_session
from app.main import app


@pytest.fixture(name="client")
def client_fixture():
    engine = create_engine(
        "sqlite://",
        connect_args={"check_same_thread": False},
        poolclass=StaticPool,
    )
    SQLModel.metadata.create_all(engine)

    def override():
        with Session(engine) as session:
            yield session

    app.dependency_overrides[get_session] = override
    yield TestClient(app)
    app.dependency_overrides.clear()


def test_create_and_get_place(client):
    payload = {"name": "Grand Park", "latitude": 10.8, "longitude": 106.7}
    r = client.post("/places", json=payload)
    assert r.status_code == 201
    place_id = r.json()["id"]
    r = client.get(f"/places/{place_id}")
    assert r.status_code == 200
    assert r.json()["name"] == "Grand Park"


def test_invalid_latitude_returns_422(client):
    r = client.post("/places", json={"name": "X", "latitude": 99, "longitude": 0})
    assert r.status_code == 422


def test_import_must_be_approved_before_public(client):
    body = {"google_place_id": "g1", "name": "Cafe", "latitude": 10.0, "longitude": 106.0}
    cid = client.post("/admin/imports", json=body).json()["id"]
    assert client.get("/places").json() == []
    assert client.post(f"/admin/imports/{cid}/approve").status_code == 200
    assert len(client.get("/places").json()) == 1
python -m pytest

Vòng đời một request (Request lifecycle)

Một request đi lần lượt qua các trạm: Client (HTTP request) → Uvicorn (server ASGI) → Routing (khớp path và method) → Validation (Pydantic, lỗi 422) → Depends (session, auth) → Hàm xử lý (logic của bạn) → Response (response_model, JSON).

Dữ liệu sai bị chặn ở bước Validation, trước khi hàm của bạn chạy. FastAPI sinh tài liệu OpenAPI từ chính type hints và models, nên /docs luôn khớp với code.

Kiến trúc các lớp (Layered structure of the project)

  • Client / Swagger UI /docs — Gửi request, xem tài liệu.
  • Routers — places.py, imports.py: endpoint và mã trạng thái.
  • Schemas — PlaceCreate, PlaceRead, ImportCreate, ImportRead.
  • Table models — Place, ImportCandidate (SQLModel).
  • Database — SQLite qua Session (dependency get_session).

Trạng thái một ứng viên import (Import candidate state machine)

Luồng trạng thái của một ứng viên import:

  • POST /admin/imports → 201 Created → trạng thái pending (chưa công khai).
  • approve (200) → approved: tạo một Place công khai, sau đó thấy được ở GET /places.
  • reject (200) → rejected: không công khai.

Duyệt lần hai trên ứng viên đã approved hoặc rejected trả 409. Ứng viên pending không bao giờ hiện ở GET /places.

HTTP, CRUD và mã trạng thái (Methods and status codes)

Phương thứcDùng đểMã thường trả vềVí dụ trong dự án
GETĐọc200, 404GET /places/1
POSTTạo mới, hoặc thực hiện một hành động201, 409, 422POST /places
PUT / PATCHCập nhật toàn bộ hoặc một phần200, 404, 422Bài tập mở rộng
DELETEXóa204, 404DELETE /places/1

200 thành công, 201 đã tạo, 204 thành công không có nội dung, 404 không tìm thấy, 409 xung đột trạng thái, 422 dữ liệu gửi lên không hợp lệ.

Sân chơi API (mô phỏng) (API playground simulation)

Bản tương tác có một mô phỏng chạy ngay trong trình duyệt để bạn thấy mã trạng thái và phản hồi của Mini Places API, không phải server thật. Các nút mẫu gợi ý theo thứ tự:

  1. Tạo địa điểm hợp lệ — POST /places với body hợp lệ → 201.
  2. Vĩ độ sai — POST /places với latitude: 99 → 422.
  3. Danh sách — GET /places → 200.
  4. Không tìm thấy — GET /places/999 → 404.
  5. Id không phải số — GET /places/abc → 422.
  6. Nhập ứng viên — POST /admin/imports → 201.
  7. Duyệt ứng viên 1 — POST /admin/imports/1/approve → 200.
  8. Duyệt lần hai — POST /admin/imports/1/approve → 409.
  9. Xóa địa điểm 1 — DELETE /places/1 → 204.

Chuyên sâu (Advanced)

Những chủ đề đưa dự án từ bài tập lên mức dùng thật.

def hay async def? (Sync vs async endpoints)

Dùng async def khi bên trong bạn await các thư viện bất đồng bộ. Hàm def thường được FastAPI chạy trong thread pool, nên gọi code chặn (blocking) trong def là chấp nhận được. Gọi code chặn bên trong async def sẽ làm đứng cả event loop.

Dependency Injection (Depends)

Khai báo thứ endpoint cần (session cơ sở dữ liệu, người dùng hiện tại, tham số phân trang) bằng Depends. Dễ tái sử dụng và dễ thay thế khi test (dependency_overrides).

Cấu hình qua môi trường (Settings)

Không ghi cứng chuỗi kết nối hay khóa bí mật. Dùng biến môi trường hoặc file .env với pydantic-settings, và đừng commit file bí mật.

Xác thực (Authentication)

Các endpoint /admin/... trong bài mẫu chưa có bảo vệ. Thực tế cần đăng nhập (ví dụ OAuth2 với JWT) và phân quyền để chỉ admin duyệt được dữ liệu.

CORS (Cross-origin requests)

Khi frontend ở domain khác, trình duyệt chặn nếu server không cho phép. Chỉ liệt kê những origin cần thiết, tránh dùng dấu * ở môi trường thật.

Migration (Schema changes)

create_all phù hợp để học. Khi dữ liệu đã có thật, dùng công cụ migration (ví dụ Alembic) để thay đổi bảng an toàn.

Mã mẫu mở rộng (Snippets for the next level)

app/config.py:

from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    database_url: str = "sqlite:///places.db"
    model_config = SettingsConfigDict(env_file=".env")


settings = Settings()
# cài thêm: pip install pydantic-settings

Thêm CORS vào app/main.py:

from fastapi.middleware.cors import CORSMiddleware

app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:3000"],
    allow_methods=["*"],
    allow_headers=["*"],
)

Dockerfile:

FROM python:3.12-slim
WORKDIR /code
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app app
CMD ["fastapi", "run", "app/main.py", "--port", "80"]

Trong Dockerfile hãy dùng phiên bản Python mà dự án và các thư viện của bạn hỗ trợ.

Checklist trước khi triển khai (Production checklist)

Hạng mụcCần làm
Bảo mậtXác thực và phân quyền cho /admin, che khóa bí mật, giới hạn CORS, kiểm tra đầu vào
Dữ liệuChuyển từ SQLite sang cơ sở dữ liệu máy chủ, dùng migration, có sao lưu
Chất lượngTest cho từng endpoint, chạy test tự động trong CI, kiểm tra kiểu dữ liệu
Vận hànhGhi log, theo dõi lỗi, endpoint /health, chạy bằng fastapi run hoặc Uvicorn trong container
Hợp đồng APIĐặt phiên bản, phân trang, mã lỗi thống nhất, tài liệu OpenAPI luôn cập nhật

Bài tập mở rộng (Next challenges)

PUT/PATCH cập nhật địa điểm · Lọc theo category · Tìm theo bán kính · Đăng nhập JWT cho /admin · Nhật ký duyệt (audit) · Docker Compose với PostgreSQL.

Từ vựng Anh – Việt (Vocabulary)

Mười sáu thuật ngữ, kèm câu ví dụ.

EnglishTiếng ViệtExample
Virtual environmentMôi trường ảoActivate the virtual environment before installing packages.
InterpreterTrình thông dịchThe interpreter runs your .py file line by line.
Module / PackageModule / GóiEach .py file is a module; a folder with __init__.py is a package.
List comprehensionBiểu thức tạo listA list comprehension builds a new list in one line.
DecoratorBộ trang tríThe @app.get decorator registers the endpoint.
Type hintGợi ý kiểuA type hint documents what a function expects.
DataclassLớp dữ liệuA dataclass saves you from writing __init__.
ExceptionNgoại lệCatch the exception instead of crashing.
Async / awaitBất đồng bộUse await to wait without blocking the loop.
EndpointĐiểm cuối APIEach endpoint maps a path and a method to a function.
Path operationThao tác trên đường dẫnA path operation is a function bound to a path.
Dependency injectionTiêm phụ thuộcDepends injects a database session into the endpoint.
Schema / ModelLược đồ / Mô hìnhThe model validates the request body.
ORMÁnh xạ đối tượng - quan hệAn ORM maps Python classes to database tables.
Status codeMã trạng thái HTTPA 422 status code means the input is invalid.
MigrationDi chuyển lược đồUse a migration to change the table safely.

Mini game: Lập trình viên Python tập sự (Review · Ôn tập)

Mười câu hỏi. Trả lời đúng được 10 điểm, trả lời đúng liên tiếp được thêm điểm thưởng. Đáp án:

  1. Lệnh nào tạo môi trường ảo tên .venv? → python3 -m venv .venv. Sau đó kích hoạt nó rồi mới cài thư viện.
  2. Kiểu dữ liệu nào sau đây là bất biến? → tuple. Tuple không cho gán lại phần tử.
  3. 10 // 3 cho kết quả nào? → 3. // là chia lấy phần nguyên.
  4. Type hints trong Python có tác dụng thế nào? → Giúp đọc code và công cụ kiểm tra, còn Pydantic dùng chúng để validate. Bản thân Python không tự ép kiểu lúc chạy.
  5. Lệnh fastapi dev làm gì? → Chạy server phát triển có tự nạp lại khi sửa code. Nó tìm app trong file, rồi chạy bằng Uvicorn.
  6. Tài liệu tương tác tự sinh của FastAPI nằm ở đường dẫn nào? → /docs. Còn có /redoc là giao diện tài liệu thứ hai.
  7. Gửi body không đúng schema Pydantic, FastAPI trả mã nào? → 422. 422 Unprocessable Entity: dữ liệu gửi lên không hợp lệ.
  8. Trong async def, điều nào là đúng? → Dùng await với thư viện bất đồng bộ, tránh gọi code chặn. Code chặn làm đứng cả vòng lặp sự kiện.
  9. Trong Mini Places API, ứng viên import pending có hiện ở GET /places không? → Không, chỉ khi được approve mới tạo Place công khai.
  10. Trong Pydantic v2, phương thức nào xuất model ra dict? → model_dump(), thay cho .dict() của Pydantic v1.

Học Python 3 và FastAPI. Code mang tính minh họa, hãy chạy và kiểm tra trên môi trường của bạn, và đối chiếu tài liệu chính thức của Python, FastAPI, Pydantic, SQLModel. Số giờ học là ước tính.