API فقط برای خواندن نیست؛ کلاینت باید بتواند سرور تازه ثبت کند و نتیجهٔ عملیات را دقیق بفهمد. در این شب بدنهٔ JSON را با مدل Pydantic اعتبارسنجی می‌کنیم و برای ایجاد، تکرار و نبود منبع کدهای متفاوت می‌دهیم. در پایان دادهٔ نامعتبر پیش از ورود به منطق ثبت رد می‌شود و کلاینت برای تشخیص موفقیت به متن پیام وابسته نیست.

قرارداد ورودی را پیش از مسیر بنویسید#

یک دیکشنری دلخواه نمی‌گوید کدام فیلد لازم است، آدرس باید چه شکلی داشته باشد یا درگاه در چه بازه‌ای قرار بگیرد. مدل Pydantic این قرارداد را بیان می‌کند. برخلاف نشانهٔ نوع عادی در پایتون، مدل هنگام ساخته‌شدن واقعاً داده را بررسی می‌کند. در FastAPI، پارامتری که نوع آن مدل Pydantic است معمولاً از بدنهٔ درخواست گرفته می‌شود.

پروژهٔ تازه را در ~/python-backend/night-08 بسازید، محیط مجازی را فعال و fastapi[standard] را نصب کنید. نمونهٔ این شب به Pydantic نسخهٔ دوم و روش model_dump() متکی است. فایل main.py زیر یک برنامهٔ کامل و مستقل است؛ آن را جایگزین محتوای فایل قبلی کنید و کد کامل دو شب را پشت سر هم نچسبانید.

from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel, Field, IPvAnyAddress

app = FastAPI(title="Server Inventory API")


class ServerCreate(BaseModel):
    hostname: str = Field(min_length=1, max_length=63)
    address: IPvAnyAddress
    role: str = Field(min_length=1)
    port: int = Field(ge=1, le=65535)
    online: bool = True


servers = [{
    "id": 1, "hostname": "pve01", "address": "192.168.10.11",
    "role": "hypervisor", "port": 8006, "online": True,
}]


@app.get("/servers")
def read_servers():
    return {"servers": servers, "total": len(servers)}


@app.get("/servers/{server_id}")
def read_server(server_id: int):
    for server in servers:
        if server["id"] == server_id:
            return server
    raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
                        detail="Server not found")


@app.post("/servers", status_code=status.HTTP_201_CREATED)
def create_server(server: ServerCreate):
    for existing in servers:
        if existing["hostname"] == server.hostname:
            raise HTTPException(status_code=status.HTTP_409_CONFLICT,
                                detail="Hostname already exists")
    record = {"id": len(servers) + 1, **server.model_dump(mode="json")}
    servers.append(record)
    return record

Field(min_length=1) خالی‌بودن رشته را رد می‌کند، ولی صرفاً از روی این شرط نمی‌توان معتبر بودن کامل نام میزبان را نتیجه گرفت؛ رشتهٔ دارای فاصله نیز ممکن است پذیرفته شود. IPvAnyAddress آدرس IPv4 یا IPv6 معتبر می‌خواهد. ge و le حدود شامل برابری هستند. فیلد online با پیش‌فرض True را می‌توان حذف کرد؛ سایر فیلدها چون پیش‌فرض ندارند، الزامی‌اند.

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

ثبت موفق و نمایش نتیجه#

سرور را با fastapi dev main.py اجرا کنید. در ترمینال دیگر درخواست زیر را بفرستید. هدر Content-Type می‌گوید بدنه JSON است. بدنه با نقل‌قول تکی پوسته پوشیده شده تا نقل‌قول‌های دوتایی JSON دست‌نخورده بمانند. گزینهٔ -i لازم است تا بتوانید کد ایجاد را نیز ببینید.

curl -i -X POST http://127.0.0.1:8000/servers \
  -H 'Content-Type: application/json' \
  -d '{"hostname":"nexus01","address":"192.168.10.20","role":"registry","port":8081}'
curl http://127.0.0.1:8000/servers

پاسخ نخست باید 201 و رکوردی با شناسهٔ دو و online: true داشته باشد. پاسخ دوم دو سرور را نشان می‌دهد. عبارت ** در ساخت دیکشنری، کلیدها و مقدارهای دیکشنری مدل را در رکورد جدید باز می‌کند. شناسه را برنامه تعیین می‌کند و جزو مدل ورودی نیست. در نمونهٔ حافظه‌ای، این روش فقط تا زمانی مناسب است که حذف و نوشتن هم‌زمان نداریم.

model_dump(mode="json") دیکشنری با مقدارهای سازگار با JSON می‌دهد. برای نمونه شیء آدرس IP به نمایش متنی تبدیل می‌شود. این روش خودش رشتهٔ JSON تولید نمی‌کند؛ تبدیل دیکشنری به بدنهٔ HTTP را FastAPI انجام می‌دهد. تفاوت دیکشنری، متن JSON و شیء مدل را در ذهن جدا نگه دارید تا از تبدیل دوباره و خروجی نقل‌قول‌خورده جلوگیری شود.

خطای ورودی با تعارض فرق دارد#

همان POST را دوباره بفرستید؛ این بار نام موجود است و 409 انتظار می‌رود. ورودی از نظر شکل معتبر است ولی با وضعیت فعلی مجموعه تعارض دارد. سپس در همان درخواست، درگاه را 70000 و نام را مقدار تازه بگذارید. باید 422 دریافت کنید و رکورد تازه‌ای وارد فهرست نشود. اعتبارسنجی پیش از اجرای تابع مسیر انجام می‌شود.

آدرس not-an-ip و حذف فیلد الزامی role را هم جداگانه امتحان کنید. در هر آزمون فقط یک شرط را خراب کنید تا علت پاسخ روشن بماند. اگر هم نام تکراری و هم درگاه نامعتبر بفرستید، ممکن است مرحلهٔ اعتبارسنجی پیش از رسیدن به بررسی تکرار درخواست را رد کند. این نتیجه تناقض نیست؛ ترتیب لایه‌های پردازش را نشان می‌دهد.

HTTPException باید با raise ایجاد شود؛ بازگرداندن آن با return رفتار موردنظر را ندارد. raise اجرای مسیر را قطع می‌کند و FastAPI آن را به پاسخ خطا تبدیل می‌کند. برای خواندن شناسهٔ موجود 200 و برای شناسهٔ ناموجود 404 بدهید. متن detail به فهم خطا کمک می‌کند، اما برنامهٔ کلاینت باید در درجهٔ اول کد و قرارداد پاسخ را بررسی کند.

وضعیتکد در قرارداد این دوره
ایجاد رکورد201
نام تکراری409
شناسهٔ عددی ناموجود404
ورودی نامعتبر مدل422
نیاز به اعتبار دسترسی401
نداشتن مجوز پس از شناسایی403
شکست غیرمنتظرهٔ برنامه500

خطای عادی کلاینت را عمداً 500 نکنید؛ چنین کاری پایش خطاهای واقعی سرور را هم دشوار می‌کند. برای عملیات نامناسبی که در قرارداد جداگانه تعریف شده ممکن است 400 مناسب باشد، اما در این نمونه همان خطاهای مشخص بالا کافی‌اند. مقدارهای ثابت fastapi.status خواندن منظور کد را ساده‌تر می‌کنند؛ استفاده از عدد هم از نظر فنی ممکن است.

تمرین ثبت بررسی سلامت#

در checks_api.py مدل CheckCreate با نام یک تا پنجاه نویسه، target_url از نوع HttpUrl، مهلت صحیح یک تا سی ثانیه و enabled با پیش‌فرض True بسازید. HttpUrl ساختار URL را بررسی می‌کند؛ این بررسی به معنی قابل دسترس بودن سرویس یا امن بودن اتصال به هر مقصد نیست. فعلاً فقط رکورد را ذخیره می‌کنیم و به نشانی دریافتی وصل نمی‌شویم.

یک بررسی اولیه داشته باشید و مسیرهای خواندن فهرست، خواندن شناسه و ایجاد را اضافه کنید. ایجاد Grafana با نشانی https://grafana.example.com/health و مهلت پنج باید 201 بدهد. تکرار همان نام 409، شناسهٔ ناموجود 404 و مهلت شصت یا URL خراب 422 می‌دهد. پس از هر خطا تعداد فهرست را بخوانید تا مطمئن شوید دادهٔ نامعتبر افزوده نشده است.

برای خودآزمایی توضیح دهید Pydantic چه زمانی اجرا می‌شود، چرا 201 ایجاد را دقیق‌تر بیان می‌کند و تفاوت نبود رکورد با نام تکراری چیست. داده‌های این شب پس از راه‌اندازی دوباره به وضعیت اولیه برمی‌گردند؛ در شب بعد پایگاه داده جای فهرست حافظه‌ای را می‌گیرد.

برای بررسی عدم تغییر داده پس از خطا، پیش از درخواست نامعتبر تعداد رکوردها را یادداشت کنید و بعد دوباره فهرست را بخوانید. برابر بودن تعداد لازم است، ولی اگر عملیات امکان ویرایش داشت کافی نبود؛ آن‌وقت باید محتوای رکوردها را هم مقایسه می‌کردیم. در این شب فقط افزودن داریم، بنابراین شمارش همراه بررسی نام‌ها شواهد روشنی می‌دهد. این شیوه، آزمون منفی را به اثری قابل مشاهده وصل می‌کند و مانع می‌شود تنها به دریافت یک کد خطا راضی شویم.

منابع#