Khi form đăng nhập gửi dữ liệu dạng application/x-www-form-urlencoded mà endpoint FastAPI của bạn lại khai báo JSON body, server trả về lỗi 422 dù tài khoản đúng. Giải pháp là dùng Form() cho từng trường, hoặc OAuth2PasswordRequestForm cho endpoint /token chuẩn OAuth2. Dùng cách này khi bạn viết form login, tích hợp nút Authorize của Swagger UI, hoặc nhận dữ liệu từ form HTML truyền thống.
Vấn đề
FastAPI mặc định hiểu tham số kiểu Pydantic model là JSON body. Nhưng form HTML truyền thống và nhiều client OAuth2 không gửi JSON — chúng gửi dữ liệu dạng application/x-www-form-urlencoded, tức các cặp username=linh&password=mat-khau-dung trong body. Khi đó FastAPI không tìm thấy JSON body nên trả về 422, dù tài khoản và mật khẩu hoàn toàn đúng.
Ví dụ, endpoint login viết theo kiểu JSON dưới đây sẽ thất bại khi client gửi form:
from pydantic import BaseModelfrom fastapi import FastAPIapp = FastAPI()class LoginData(BaseModel): username: str password: str@app.post("/login")def login(data: LoginData): return {"user": data.username}
Gửi JSON thì endpoint này chạy bình thường, nhưng gửi form thì nhận 422. Lỗi này rất dễ gặp khi bạn bấm nút Authorize trên Swagger UI, vì nút này gửi form theo chuẩn OAuth2 chứ không gửi JSON.
Giải pháp
Dùng Form() cho từng trường
Khai báo từng trường bằng Form() thay vì gom vào một Pydantic model. Form() hỗ trợ đầy đủ các ràng buộc validate như Query() hay Path(), ví dụ min_length:
from typing import Annotatedfrom fastapi import FastAPI, Formapp = FastAPI()@app.post("/login")def login( username: Annotated[str, Form()], password: Annotated[str, Form(min_length=8)],): return {"user": username}
Endpoint này nhận form đúng chuẩn, trả 200 khi dữ liệu hợp lệ, và vẫn trả 422 khi mật khẩu ngắn hơn 8 ký tự hoặc thiếu trường. Lưu ý: muốn dùng Form() bạn phải cài python-multipart (pip install python-multipart), nếu không FastAPI báo lỗi ngay khi khởi động.
Dùng OAuth2PasswordRequestForm cho endpoint /token
Nếu bạn xây endpoint /token theo chuẩn OAuth2, đừng tự khai báo từng trường. FastAPI có sẵn OAuth2PasswordRequestForm — một dependency chứa sẵn các trường username, password, scope, grant_type, client_id và client_secret:
from typing import Annotatedfrom fastapi import Depends, FastAPI, HTTPException, statusfrom fastapi.security import OAuth2PasswordRequestFormapp = FastAPI()USERS = {"linh": "mat-khau-dung"}@app.post("/token")def token(form: Annotated[OAuth2PasswordRequestForm, Depends()]): if USERS.get(form.username) != form.password: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Sai tên đăng nhập hoặc mật khẩu", ) return {"access_token": "token-gia-lap", "token_type": "bearer"}
Cách này vừa đúng chuẩn OAuth2, vừa tương thích ngay với nút Authorize trên Swagger UI và mọi client OAuth2 khác. Trong thực tế, bạn thay đoạn kiểm tra mật khẩu trong dict USERS bằng truy vấn database và ký JWT thật.
Áp dụng vào dự án của bạn
Quy tắc nhớ nhanh: client gửi JSON thì dùng Pydantic model, client gửi form thì dùng Form(), còn endpoint /token thì dùng OAuth2PasswordRequestForm. Trước khi deploy, hãy test cả hai kiểu gửi bằng TestClient: form phải trả 200, JSON phải trả 422. Nếu dự án của bạn đã có endpoint login trả 422 một cách khó hiểu, khả năng cao nguyên nhân nằm ở đúng chỗ này.
Bài viết liên quan
- FastAPI: bảo vệ route bằng dependency JWT/OAuth2 tái sử dụng, không lặp code xác thực
- FastAPI: validate dữ liệu đầu vào chuẩn chỉnh bằng Pydantic Field và validator tùy chỉnh
- FastAPI: chia API thành nhiều module bằng APIRouter, khỏi dồn hết vào main.py
Câu hỏi thường gặp
Khi nào dùng Form() thay vì Pydantic model?
Khi client gửi dữ liệu dạng form (application/x-www-form-urlencoded), ví dụ form HTML, nút Authorize của Swagger UI, hoặc client OAuth2. Client gửi JSON thì cứ dùng Pydantic model như bình thường.
Vì sao endpoint login trả 422 dù tài khoản đúng?
Vì endpoint khai báo JSON body nhưng client gửi form. FastAPI không parse được JSON nên báo lỗi validate. Đổi các trường sang Form() là hết lỗi.
OAuth2PasswordRequestForm khác gì Form() thường?
OAuth2PasswordRequestForm là một dependency có sẵn, gom đủ các trường chuẩn OAuth2 như username, password, scope và grant_type. Bạn chỉ cần Depends() là dùng được, không phải khai báo từng trường bằng Form().
Có dùng chung Form() và File trong một endpoint được không?
Được. Khi endpoint có cả Form() và File(), FastAPI tự hiểu request là multipart/form-data — đúng kiểu form upload file kèm trường text.
Thiếu python-multipart thì lỗi ra sao?
FastAPI báo lỗi ngay khi khởi động app, vì nó cần thư viện này để đọc dữ liệu form. Cài bằng pip install python-multipart rồi chạy lại là xong.
💬 Bạn đã thử cách này trong dự án của mình chưa? Nếu gặp lỗi hoặc có cách làm gọn hơn, để lại bình luận bên dưới — mình đọc hết và sẽ bổ sung những ý hay vào bài.
