قدمهای اول FastAPI
اولین endpointهای واقعی را مینویسیم: پارامتر مسیر و پرسوجو را میخوانیم، بدنه JSON میگیریم، با کد وضعیت درست پاسخ میدهیم و API را با روترها مرتب میکنیم. در پایان فصل، آموزشگاه اولین API خود را دارد: دورهها و کاربران با CRUD کامل، که درخواست به درخواست در Bruno آزموده میشود.
۱. درخواست، مسیر، تابع، پاسخ
هر درخواستی که به یک برنامه FastAPI میرسد این مسیر را طی میکند:
- سرور uvicorn درخواست را از شبکه میگیرد و به برنامه میدهد.
- FastAPI با نگاه به روش و نشانی، تابع مسیر (path operation) مناسب را پیدا میکند.
- پارامترهای تابع را از روی راهنمای نوع آنها از نشانی، رشته پرسوجو یا بدنه میخواند و بررسی میکند.
- تابع شما اجرا میشود و یک مقدار برمیگرداند.
- 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
@app.get('/')یک دکوراتور است، همان که در فصل اول مرور کردیم: «وقتیGET /رسید این تابع را صدا بزن».- تابع یک دیکشنری برمیگرداند و FastAPI آن را به JSON تبدیل میکند.
reload=Trueسرور را با هر ذخیره فایل دوباره راه میاندازد. فقط در زمان توسعه./docsرابط Swagger و/redocهمان مستند برای خواندن است. docstring تابع در مستند نشان داده میشود.
۲. پارامتر مسیر
بخش متغیر نشانی داخل آکولاد میآید: /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
status_code=status.HTTP_201_CREATEDدر دکوراتور، کد موفقیت همان endpoint را تعیین میکند.- پاسخ ۲۰۴ بدنه ندارد؛ تابع چیزی برنمیگرداند (
-> None). - ثابتهای
fastapi.statusاز عددهای خالی خواناترند.
۶. پنج روش روی یک منبع: 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؟
- PUT تمام منبع جدید را میفرستد. فیلد جاافتاده خطاست (۴۲۲).
- PATCH فقط آنچه عوض میشود را میفرستد:
{"price": 100}. همه فیلدهای مدل اختیاریاند وmodel_dump(exclude_unset=True)فقط فیلدهایی را میدهد که کلاینت واقعاً فرستاده است.
تابع کمکی 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 | مستند و آزمایشگاه زنده |
تمرینهای این فصل
- در
02_path_parametersمسیر/courses/most-expensiveرا اضافه کنید. یک بار آن را بعد از/courses/{course_id}بگذارید و خطا را توضیح دهید. - در
03_query_parametersپارامترmin_priceوsort(با مقدارهایpriceیاtitle) اضافه کنید. باsort=bananaچه میشود؟ - در پروژه endpoint
GET /courses/{course_id}/seatsبسازید که ظرفیت و صندلیهای خالی را برگرداند. - ساختن دوره با قیمت منفی یا ظرفیت صفر را با
HTTPException(400)رد کنید. (فصل سوم این را بهتر حل میکند.) - برای کاربران
PUTوPATCHبنویسید، با ۴۰۴ و ۴۰۹ (ایمیلی که کاربر دیگری دارد). - روتری برای ثبتنام بسازید:
POST /courses/{course_id}/students/{user_id}؛ ثبتنام دوباره ۴۰۹ و دوره یا کاربر ناموجود ۴۰۴. - برای هر endpoint تازه یک درخواست موفق و یک درخواست ناموفق در Bruno اضافه کنید.
- تکلیف: یک API کتابخانه با همین ساختار بسازید: کتابها با CRUD کامل، فیلتر
?available=true&author=...وPOST /books/{id}/borrowکه برای کتاب امانترفته ۴۰۹ بدهد. دستکم ده درخواست Bruno که چهار تای آنها عمداً ناموفق باشد.