توسعه بک‌اند با FastAPI فهرست مطالب
فصل ۲

قدم‌های اول FastAPI

اولین endpointهای واقعی را می‌نویسیم: پارامتر مسیر و پرس‌وجو را می‌خوانیم، بدنه JSON می‌گیریم، با کد وضعیت درست پاسخ می‌دهیم و API را با روترها مرتب می‌کنیم. در پایان فصل، آموزشگاه اولین API خود را دارد: دوره‌ها و کاربران با CRUD کامل، که درخواست به درخواست در Bruno آزموده می‌شود.

در این فصل می‌خوانید
  1. درخواست، مسیر، تابع، پاسخ
  2. پارامتر مسیر
  3. پارامتر پرس‌وجو
  4. بدنه درخواست
  5. کدهای وضعیت درست
  6. پنج روش روی یک منبع: CRUD کامل
  7. روترها: تقسیم API به فایل‌ها
  8. پروژه: API آموزشگاه، نسخه یک
  9. خلاصه فصل

۱. درخواست، مسیر، تابع، پاسخ

هر درخواستی که به یک برنامه FastAPI می‌رسد این مسیر را طی می‌کند:

  1. سرور uvicorn درخواست را از شبکه می‌گیرد و به برنامه می‌دهد.
  2. FastAPI با نگاه به روش و نشانی، تابع مسیر (path operation) مناسب را پیدا می‌کند.
  3. پارامترهای تابع را از روی راهنمای نوع آنها از نشانی، رشته پرس‌وجو یا بدنه می‌خواند و بررسی می‌کند.
  4. تابع شما اجرا می‌شود و یک مقدار برمی‌گرداند.
  5. FastAPI آن مقدار را به JSON تبدیل می‌کند و با کد وضعیت مناسب پس می‌فرستد.
import uvicorn
from fastapi import FastAPI

app = FastAPI(title='Training Center API', version='0.1.0')


@app.get('/')
def home() -> dict[str, str]:
    return {'message': 'Welcome to the Training Center API'}


@app.get('/about')
def about() -> dict[str, str | int]:
    """This docstring appears in /docs."""
    return {'institute': 'Kadoos', 'city': 'Rasht', 'sessions': 16}


if __name__ == '__main__':
    uvicorn.run('app:app', reload=True)

کوچک‌ترین API: نمونه 01_first_app/app.py

۲. پارامتر مسیر

بخش متغیر نشانی داخل آکولاد می‌آید: /courses/{course_id}. نوع int باعث می‌شود FastAPI مقدار را به عدد تبدیل کند و اگر نشد، پیش از اجرای تابع شما پاسخ ۴۲۲ بدهد.

import uvicorn
from fastapi import FastAPI, HTTPException

app = FastAPI()

COURSES = {
    1: {'id': 1, 'title': 'Python Basics', 'price': 2_500_000},
    2: {'id': 2, 'title': 'FastAPI', 'price': 4_800_000},
    3: {'id': 3, 'title': 'HTML and CSS', 'price': 1_900_000},
}


@app.get('/courses/cheapest')
def cheapest_course() -> dict:
    return min(COURSES.values(), key=lambda course: course['price'])


@app.get('/courses/{course_id}')
def get_course(course_id: int) -> dict:
    if course_id not in COURSES:
        raise HTTPException(status_code=404, detail=f'Course {course_id} not found')
    return COURSES[course_id]


@app.get('/users/{username}/courses/{course_id}')
def user_course(username: str, course_id: int) -> dict:
    """Two path parameters in one URL."""
    return {'username': username, 'course_id': course_id}


if __name__ == '__main__':
    uvicorn.run('app:app', reload=True)

مسیر متغیر، ۴۰۴ و ترتیب مسیرها: نمونه 02_path_parameters/app.py

GET /courses/2          200 {"id":2,"title":"FastAPI","price":4800000}
GET /courses/9          404 {"detail":"Course 9 not found"}
GET /courses/abc        422 {"detail":[{"type":"int_parsing","loc":["path","course_id"],...}]}
GET /courses/cheapest   200 {"id":3,"title":"HTML and CSS","price":1900000}
ترتیب مسیرها مهم است

مسیر /courses/cheapest باید پیش از /courses/{course_id} تعریف شود. در غیر این صورت FastAPI کلمه cheapest را شناسه دوره فرض می‌کند، نمی‌تواند آن را به عدد تبدیل کند و ۴۲۲ برمی‌گرداند.

دوره‌ای که وجود ندارد اشتباه کلاینت است: HTTPException(status_code=404). هرگز ۲۰۰ با {"error": ...} برنگردانید؛ کلاینت اول به کد وضعیت نگاه می‌کند.

۳. پارامتر پرس‌وجو

هر پارامتری که در مسیر نیامده باشد از بخش بعد از ? خوانده می‌شود:

import uvicorn
from fastapi import FastAPI

app = FastAPI()

COURSES = [
    {'id': 1, 'title': 'Python Basics', 'price': 2_500_000, 'level': 'beginner'},
    {'id': 2, 'title': 'FastAPI', 'price': 4_800_000, 'level': 'advanced'},
    {'id': 3, 'title': 'HTML and CSS', 'price': 1_900_000, 'level': 'beginner'},
    {'id': 4, 'title': 'SQL', 'price': 2_900_000, 'level': 'intermediate'},
]


@app.get('/courses')
def list_courses(
    level: str | None = None,
    max_price: int | None = None,
    q: str | None = None,
    limit: int = 10,
) -> list[dict]:
    result = COURSES
    if level is not None:
        result = [c for c in result if c['level'] == level]
    if max_price is not None:
        result = [c for c in result if c['price'] <= max_price]
    if q:
        result = [c for c in result if q.lower() in c['title'].lower()]
    return result[:limit]


@app.get('/search')
def search(q: str) -> dict:
    """`q` has no default, so it is required: /search without ?q= gives 422."""
    return {'you searched for': q}


if __name__ == '__main__':
    uvicorn.run('app:app', reload=True)

فیلترها در رشته پرس‌وجو: نمونه 03_query_parameters/app.py

تعریفمعنی
limit: int = 10اختیاری با مقدار پیش‌فرض
level: str | None = Noneاختیاری؛ «فرستاده نشده» یعنی None
q: strاجباری؛ اگر نیاید ۴۲۲

قاعده ساده‌ای برای انتخاب: پارامتر مسیر می‌گوید کدام منبع (/courses/2) و پارامتر پرس‌وجو می‌گوید فهرست را چگونه نشان بده (فیلتر، مرتب‌سازی، صفحه).

۴. بدنه درخواست

برای فرستادن داده به سرور، کلاینت JSON را در بدنه POST می‌گذارد. شکل مورد انتظار را با یک مدل کوچک Pydantic توصیف می‌کنیم؛ پارامتری که نوعش BaseModel باشد از بدنه خوانده می‌شود. کل فصل سوم درباره Pydantic است و اینجا فقط به حداقل آن نیاز داریم.

import uvicorn
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()


class NewCourse(BaseModel):
    title: str
    price: int
    capacity: int = 20  # optional in the JSON, with a default


COURSES: list[dict] = []


@app.post('/courses')
def create_course(course: NewCourse) -> dict:
    new = {'id': len(COURSES) + 1, **course.model_dump()}
    COURSES.append(new)
    return new


@app.get('/courses')
def list_courses() -> list[dict]:
    return COURSES


if __name__ == '__main__':
    uvicorn.run('app:app', reload=True)

ساختن دوره: نمونه 04_request_body/app.py

۵. کدهای وضعیت درست

import uvicorn
from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel

app = FastAPI()


class NewUser(BaseModel):
    email: str
    full_name: str


USERS: dict[int, dict] = {1: {'id': 1, 'email': 'sara@example.com', 'full_name': 'Sara'}}


@app.post('/users', status_code=status.HTTP_201_CREATED)
def create_user(user: NewUser) -> dict:
    if any(u['email'] == user.email for u in USERS.values()):
        raise HTTPException(status.HTTP_409_CONFLICT, detail='Email already registered')
    new_id = max(USERS, default=0) + 1
    USERS[new_id] = {'id': new_id, **user.model_dump()}
    return USERS[new_id]


@app.delete('/users/{user_id}', status_code=status.HTTP_204_NO_CONTENT)
def delete_user(user_id: int) -> None:
    if user_id not in USERS:
        raise HTTPException(status.HTTP_404_NOT_FOUND, detail='User not found')
    del USERS[user_id]


if __name__ == '__main__':
    uvicorn.run('app:app', reload=True)

۲۰۱، ۲۰۴، ۴۰۴ و ۴۰۹: نمونه 05_status_codes/app.py

۶. پنج روش روی یک منبع: CRUD کامل

class CourseIn(BaseModel):
    title: str
    price: int
    capacity: int


class CoursePatch(BaseModel):
    title: str | None = None
    price: int | None = None
    capacity: int | None = None


COURSES: dict[int, dict] = {1: {'id': 1, 'title': 'FastAPI', 'price': 4_800_000, 'capacity': 12}}


def get_or_404(course_id: int) -> dict:
    if course_id not in COURSES:
        raise HTTPException(status.HTTP_404_NOT_FOUND, detail='Course not found')
    return COURSES[course_id]


@app.get('/courses')
def list_courses() -> list[dict]:
    return list(COURSES.values())


@app.post('/courses', status_code=status.HTTP_201_CREATED)
def create_course(data: CourseIn) -> dict:
    new_id = max(COURSES, default=0) + 1
    COURSES[new_id] = {'id': new_id, **data.model_dump()}
    return COURSES[new_id]


@app.get('/courses/{course_id}')
def get_course(course_id: int) -> dict:
    return get_or_404(course_id)


@app.put('/courses/{course_id}')
def replace_course(course_id: int, data: CourseIn) -> dict:
    get_or_404(course_id)
    COURSES[course_id] = {'id': course_id, **data.model_dump()}
    return COURSES[course_id]


@app.patch('/courses/{course_id}')
def update_course(course_id: int, data: CoursePatch) -> dict:
    course = get_or_404(course_id)
    course.update(data.model_dump(exclude_unset=True))
    return course


@app.delete('/courses/{course_id}', status_code=status.HTTP_204_NO_CONTENT)
def delete_course(course_id: int) -> None:
    get_or_404(course_id)
    del COURSES[course_id]


if __name__ == '__main__':
    uvicorn.run('app:app', reload=True)

CRUD کامل روی دوره‌ها: نمونه 06_put_patch_delete/app.py

PUT یا PATCH؟

تابع کمکی get_or_404 نمی‌گذارد یک بررسی را پنج بار بنویسیم. در فصل چهارم همین تکرار را با وابستگی‌ها و استثناهای سفارشی از بین می‌بریم.

۷. روترها: تقسیم API به فایل‌ها

یک فایل با سی endpoint خوانا نیست. APIRouter یک «برنامه کوچک» است که endpointهای یک منبع را نگه می‌دارد:

from fastapi import APIRouter, HTTPException

router = APIRouter(prefix='/courses', tags=['courses'])

COURSES = {1: {'id': 1, 'title': 'FastAPI'}, 2: {'id': 2, 'title': 'Python'}}


@router.get('')  # the full path is /courses
def list_courses() -> list[dict]:
    return list(COURSES.values())


@router.get('/{course_id}')  # the full path is /courses/{course_id}
def get_course(course_id: int) -> dict:
    if course_id not in COURSES:
        raise HTTPException(404, detail='Course not found')
    return COURSES[course_id]

روتر دوره‌ها: نمونه 07_routers/routers/courses.py

import uvicorn
from fastapi import FastAPI

from routers import courses, users

app = FastAPI(title='Training Center API')
app.include_router(courses.router)
app.include_router(users.router)


if __name__ == '__main__':
    uvicorn.run('app:app', reload=True)

وصل کردن روترها: نمونه 07_routers/app.py

prefix به ابتدای همه مسیرهای روتر اضافه می‌شود و tags آنها را در /docs گروه‌بندی می‌کند.

۸. پروژه: API آموزشگاه، نسخه یک

هسته پایتونی فصل اول حالا پشت endpointها می‌رود. داده هنوز در حافظه است (دیکشنری‌های data.py)، پس با راه‌اندازی دوباره سرور پاک می‌شود. در فصل پنجم پایگاه داده واقعی می‌آید.

08_project/
├── main.py              برنامه و وصل کردن روترها
├── data.py              «پایگاه داده» جعلی: دو دیکشنری و یک شمارنده
├── routers/
│   ├── courses.py       فهرست با فیلتر، خواندن، ساختن، PUT، PATCH، DELETE
│   └── users.py         فهرست، خواندن، ساختن (با ۴۰۹ برای ایمیل تکراری)، حذف
└── bruno/               دوازده درخواست، چهار تای آنها عمداً ناموفق
@router.post('', status_code=status.HTTP_201_CREATED)
def create_user(data: UserIn) -> dict:
    email = data.email.lower()
    if any(user['email'] == email for user in USERS.values()):
        raise HTTPException(status.HTTP_409_CONFLICT, detail='Email already registered')
    user_id = next_id(USERS)
    USERS[user_id] = {'id': user_id, **data.model_dump(), 'email': email}
    return USERS[user_id]

۴۰۹ برای ایمیل تکراری: 08_project/routers/users.py

در Bruno پوشه 08_project/bruno را باز کنید و همه درخواست‌ها را با Runner اجرا کنید. به درخواست‌های ناموفق دقت کنید: /courses/abc چرا ۴۲۲ است و نه ۴۰۴؟ قیمت متنی چه خطایی می‌دهد؟

امتحان کنید

در Bruno بدنه‌ای بفرستید که اصلاً JSON نیست (نوع Text را انتخاب کنید و بنویسید hello). چه کدی می‌گیرید و چه کسی آن را تولید کرده است، شما یا FastAPI؟

فایل‌های تمرینی این فصل

پوشه S02-fastapi-first-steps هشت پوشه دارد. در هر کدام python app.py بزنید و http://127.0.0.1:8000/docs را باز کنید. پروژه با python main.py در پوشه 08_project اجرا می‌شود. همین سرور را با uvicorn main:app --reload هم می‌توانید اجرا کنید.

۹. خلاصه فصل

ابزاروظیفه آن
FastAPI() و uvicorn.run(...)ساختن برنامه و اجرای سرور
@app.get، post، put، patch، deleteوصل کردن روش و مسیر به تابع
/courses/{course_id}پارامتر مسیر با تبدیل خودکار
پارامتر بدون پیش‌فرضپارامتر پرس‌وجوی اجباری
مدل BaseModel در پارامترهاخواندن بدنه JSON
status_code=201کد موفقیت endpoint
HTTPException(404, detail=...)پاسخ خطای درست
model_dump(exclude_unset=True)فقط فیلدهای فرستاده‌شده، برای PATCH
APIRouter(prefix=..., tags=...)تقسیم API به فایل‌ها
/docs و /redocمستند و آزمایشگاه زنده

تمرین‌های این فصل

  1. در 02_path_parameters مسیر /courses/most-expensive را اضافه کنید. یک بار آن را بعد از /courses/{course_id} بگذارید و خطا را توضیح دهید.
  2. در 03_query_parameters پارامتر min_price و sort (با مقدارهای price یا title) اضافه کنید. با sort=banana چه می‌شود؟
  3. در پروژه endpoint GET /courses/{course_id}/seats بسازید که ظرفیت و صندلی‌های خالی را برگرداند.
  4. ساختن دوره با قیمت منفی یا ظرفیت صفر را با HTTPException(400) رد کنید. (فصل سوم این را بهتر حل می‌کند.)
  5. برای کاربران PUT و PATCH بنویسید، با ۴۰۴ و ۴۰۹ (ایمیلی که کاربر دیگری دارد).
  6. روتری برای ثبت‌نام بسازید: POST /courses/{course_id}/students/{user_id}؛ ثبت‌نام دوباره ۴۰۹ و دوره یا کاربر ناموجود ۴۰۴.
  7. برای هر endpoint تازه یک درخواست موفق و یک درخواست ناموفق در Bruno اضافه کنید.
  8. تکلیف: یک API کتابخانه با همین ساختار بسازید: کتاب‌ها با CRUD کامل، فیلتر ?available=true&author=... و POST /books/{id}/borrow که برای کتاب امانت‌رفته ۴۰۹ بدهد. دست‌کم ده درخواست Bruno که چهار تای آنها عمداً ناموفق باشد.
فصل قبل: مرور پایتون پیشرفته، HTTP و REST فصل بعد: Pydantic: اعتبارسنجی و طرح‌واره‌ها