Try โ€‚โ€‰HackMD

FastAPI

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More โ†’

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More โ†’

References

Toolkits

  • Uvicorn๏ผšๅฏฆ็พ ASGI ็š„ Web Server
  • Starlette๏ผš่ผ•้‡็ดš ASGI ็š„ Web App ๆก†ๆžถ
  • Pydantic๏ผš่ณ‡ๆ–™้ฉ—่ญ‰ๅทฅๅ…ท
  • FastAPI๏ผšASGI ็š„ Web App ๆก†ๆžถ
    • ไพ่ณด Starlette ๅ’Œ Pydantic
    • ๅฏไปฅ่‡ชๅ‹•็”Ÿๆˆๆ–‡ๆช”

Directory Structure

.
โ””โ”€โ”€ app
    โ”œโ”€โ”€ __init__.py
    โ”œโ”€โ”€ main.py          # ๅ…ฅๅฃ้ปž
    โ”œโ”€โ”€ dependencies.py  # ไพ่ณดๆณจๅ…ฅ็š„ๅ‡ฝๅผ
    โ”œโ”€โ”€ api              # API ่ทฏ็”ฑ
    โ”‚   โ”œโ”€โ”€ __init__.py
    โ”‚   โ”œโ”€โ”€ items.py
    โ”‚   โ””โ”€โ”€ users.py
    โ”œโ”€โ”€ models           # Pydantic Model
    โ”‚   โ”œโ”€โ”€ __init__.py
    โ”‚   โ”œโ”€โ”€ base.py
    โ”‚   โ”œโ”€โ”€ item.py
    โ”‚   โ””โ”€โ”€ user.py
    โ”œโ”€โ”€ schemas          # SQLAlchemy Table
    โ”‚   โ”œโ”€โ”€ __init__.py
    โ”‚   โ”œโ”€โ”€ items.py
    โ”‚   โ””โ”€โ”€ users.py
    โ””โ”€โ”€ internal         # ๅ…ง้ƒจๆฅญๅ‹™้‚่ผฏ
        โ”œโ”€โ”€ __init__.py
        โ””โ”€โ”€ ...

API document

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More โ†’
Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More โ†’

Usage

Depends

  • ไพ่ณดๆณจๅ…ฅ

    โ€‹โ€‹async def common_params(skip: int = 0, limit: int = 100):
    โ€‹โ€‹    return {"skip": skip, "limit": limit}
    
    โ€‹โ€‹@app.get("/items/")
    โ€‹โ€‹async def read_items(commons: Annotated[dict, Depends(common_params)]):
    โ€‹โ€‹    return commons
    
    ๐Ÿšจ CAUTION
    ๅช่ƒฝ็”จๆ–ผ API endpoint ็š„ handle funtion ๅ…ง็š„ Annotated ่จป้‡‹
    ๏ผˆๆƒณๅฟ…ๆ˜ฏๅœจ app decorator ๅšไบ†ไธ€ไบ›่™•็†๏ผŒ่ฉณ่ฆ‹ Annotated ้ป‘้ญ”ๆณ•๏ผ‰ใ€‚
    ็•ถ็„ถไฝ ไนŸ่ƒฝ็”จไธ‰ๆ–นๅบซ fastapi-injectable ็š„ @injectable๏ผŒ
    ่ฎ“ๅฎƒ่„ซ้›ข app decorator ไนŸ่ƒฝ้‹ไฝœใ€‚
    ๐Ÿ“— TIP
    Annotated[Type, Depends()] ็ญ‰ๅƒนๆ–ผ Annotated[Type, Depends(Type)]
    ไนŸๅฐฑๆ˜ฏ่ชช็•ถ็ตฆๅฎš None ๆ™‚๏ผŒ่‡ชๅ‹•ๅธถๅ…ฅๅ‰ๆ–นๅž‹ๆ…‹
  • ้š”ๅฑฑๆ‰“็‰› (ๅฐๆ–ผไธ€ๆ™‚ไธๅฏ่ฆ‹็š„ไพ่ณด๏ผŒๆœƒ่‡ชๅ‹•ๅพ€ๅค–ๅŽปๅฐ‹ๆ‰พ)

    โ€‹โ€‹def get_double(n: int) -> int:
    โ€‹โ€‹    return n * 2
    
    โ€‹โ€‹# ๅœจ /test?n=2 ๆ™‚๏ผŒๆœƒๅ›žๅ‚ณ 4 ๐Ÿšฉ
    โ€‹โ€‹# ๆณจๆ„๏ผšn ๅฏไปฅไธ็”จๆ˜Ž็ขบๅฏซๅœจๅƒๆ•ธ่ฃก (ๅคช็ฅžๅฅ‡ไบ†ๆˆ‘็š„ๅ‚‘ๅ…‹๐Ÿช„)
    โ€‹โ€‹@app.post("/test")
    โ€‹โ€‹async def test(doubled_number: Annotated[int, Depends(get_double)]):
    โ€‹โ€‹    return doubled_number
    

Query

@app.get("/items") # ่ฎ€ๅ– request ไธญ query ็š„ numbers ๅƒๆ•ธ
def read_items(numbers):
    return {"numbers": numbers}

Cookie - Samesite settings

@app.get("/login") # ่จญๅฎš cookie "token"
def login(response: Response):
    response.set_cookie(key="token", value="my-secret", httponly=True)
    return {"message": "logged in"}

@app.get("/logout") # ๅˆช้™ค cookie "token"
def logout(response: Response):
    response.delete_cookie(key="token")
    return {"message": "logged out"}

@app.get("/profile") # ่ฎ€ๅ– request ไธญ็š„ cookie "token"
def profile(token: Annotated[str, Cookie()]):
    if token != "my-secret":
        return {"error": "unauthorized"}
    return {"message": "Welcome back!"}
@app.get("/read-header") # ่ฎ€ๅ– request ไธญ header ็š„ User-Agent ๆฌ„ไฝ
def read_header(user_agent: Annotated[str | None, Header()] = None):
    return {"User-Agent": user_agent}

Form

class FormData(BaseModel):
    username: str
    password: str
    model_config = {"extra": "forbid"}  # ไธๅ…่จฑๅ‡บ็พๅ…ถไป–ๆฌ„ไฝ

# Form ็š„ media_type ้ธ้ …
# ้ ่จญไฝฟ็”จ application/x-www-form-urlencode
# ไนŸๅฏๆŒ‡ๅฎš multipart/form-data
@app.post("/test/")
async def test(data: Annotated[FormData, Form()]):
    return data

@app.HTTP_METHOD(...)

  • response_model=๏ผšresponse ๆŽก็”จ็š„ schema
  • deprecated=๏ผšๆฃ„็”จ
  • dependencies=๏ผšๅ…ˆ่กŒไพ่ณด
    โ€‹โ€‹def get_double(n: int) -> int:
    โ€‹โ€‹    return n * 2
    
    โ€‹โ€‹def verify_even(n: int) -> None:
    โ€‹โ€‹    if n % 2 == 1:
    โ€‹โ€‹        raise ValueError("Odd value!")
    
    โ€‹โ€‹# ๅœจ /test?n=2 ๆ™‚๏ผŒๅ…ˆๆชขๆŸฅๆ˜ฏไธๆ˜ฏๅถๆ•ธ๏ผŒๅ†ไพ†ๅ›žๅ‚ณ 4 ๐Ÿšฉ
    โ€‹โ€‹# ๅœจ /test?n=5 ๆ™‚๏ผŒๅ…ˆๆชขๆŸฅๆ˜ฏไธๆ˜ฏๅถๆ•ธ๏ผŒ็„ถๅพŒๅฐฑ็ˆ†็‚ธไบ†
    โ€‹โ€‹@app.post("/test", dependencies=[Depends(verify_even)])
    โ€‹โ€‹async def test(doubled_number: Annotated[int, Depends(get_double)]):
    โ€‹โ€‹    return doubled_number
    

Uvicorn

  • --reload๏ผš้–‹ๅ•Ÿ hot reload
    โ€‹โ€‹uvicorn <app_script>:<app_object_name> --host <host> --port <port>
    

Async

modification

  • ่ณ‡ๆ–™ๅบซ้ƒจๅˆ†

    โ€‹โ€‹from collections.abc import AsyncGenerator
    โ€‹โ€‹from typing import Any
    
    โ€‹โ€‹from sqlalchemy.ext.fastapi import AsyncSession, async_sessionmaker, create_async_engine
    
    โ€‹โ€‹from config import get_settings
    โ€‹โ€‹from models.base import Base
    
    โ€‹โ€‹engine = create_async_engine(get_settings().database_uri)
    โ€‹โ€‹AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
    
    
    โ€‹โ€‹async def get_session() -> AsyncGenerator[AsyncSession, Any, None]:
    โ€‹โ€‹    async with AsyncSessionLocal() as session:
    โ€‹โ€‹        async with session.begin():
    โ€‹โ€‹            yield session
    
    
    โ€‹โ€‹async def init_db() -> None:
    โ€‹โ€‹    async with engine.begin() as conn:
    โ€‹โ€‹        await conn.run_sync(Base.metadata.create_all)
    
    
    โ€‹โ€‹async def drop_db():
    โ€‹โ€‹    async with engine.begin() as conn:
    โ€‹โ€‹        await conn.run_sync(Base.metadata.drop_all)
    
    
    โ€‹โ€‹async def close_db() -> None:
    โ€‹โ€‹    await engine.dispose()
    
  • ่จ˜ๅพ— API ้€š้€šๆ”นๆˆ async

    โ€‹โ€‹# FastAPI ็ฏ„ไพ‹
    โ€‹โ€‹@router.post(
    โ€‹โ€‹    "/users",
    โ€‹โ€‹    response_model=UserSchema.UserRead,
    โ€‹โ€‹    status_code=status.HTTP_201_CREATED,
    โ€‹โ€‹    response_description="ๆˆๅŠŸๅปบ็ซ‹ไฝฟ็”จ่€…",
    โ€‹โ€‹    summary="ๅปบ็ซ‹ไฝฟ็”จ่€…",
    โ€‹โ€‹)
    โ€‹โ€‹async def create_user(
    โ€‹โ€‹    user_data: UserSchema.UserCreate, 
    โ€‹โ€‹    session: Annotated[AsyncSession, Depends(get_session)]
    โ€‹โ€‹):
    โ€‹โ€‹    db_user = User(user_data)
    โ€‹โ€‹    session.add(db_user)
    โ€‹โ€‹    await session.flush()
    โ€‹โ€‹    await session.refresh(db_user)
    โ€‹โ€‹    return db_user
    
    โ€‹โ€‹# ๅŸท่กŒ่ทฏๅพ‘๏ผš
    โ€‹โ€‹# 1. yield ๅ‰ (transaction starts)
    โ€‹โ€‹# 2. session.add(db_user) (session ไธญๆ–ฐๅขžไฝฟ็”จ่€…)
    โ€‹โ€‹# 3. session.flush() (ๅฐ‡ session ่ฎŠๆ›ดๅˆทๆ–ฐๅ…ฅ่ณ‡ๆ–™ๅบซ)
    โ€‹โ€‹# (ๅฆ‚ๆžœไฝ ็š„ ID ไฝฟ็”จ autoincrement๏ผŒๆญคๆ™‚่ณ‡ๆ–™ๅบซๆœƒ่‡ชๅ‹•้…็ตฆ ID)
    โ€‹โ€‹# 4. session.refresh(db_user)
    โ€‹โ€‹# (็‚บไบ†ๆŠ“ๅ–้€™ๅ€‹ ID๏ผŒๆˆ‘ๅ€‘ๅฟ…้ ˆ้‡ๆ–ฐ query ไธ€้่ณ‡ๆ–™ๅบซ๏ผŒๅฐ‡่ณ‡ๆ–™ๅˆทๆ–ฐๅ›ž ORM ๅฏฆไพ‹)
    โ€‹โ€‹# 5. yield ๅพŒ (transaction ends)
    
    โ€‹โ€‹# ๐Ÿค” ๅ€‹ไบบๆ„่ฆ‹๏ผšไธ่ฆ็”จ autoincrement ๅ•ฆ๏ผŒ็”จ UUID ๅง๏ผ
    
  • lifespan ๅฏซๆณ•

    โ€‹โ€‹from contextlib import asynccontextmanager
    
    โ€‹โ€‹from fastapi import FastAPI
    
    โ€‹โ€‹from api.items import router as items_router
    โ€‹โ€‹from api.users import router as users_router
    โ€‹โ€‹from database.session import close_db, init_db
    
    
    โ€‹โ€‹@asynccontextmanager
    โ€‹โ€‹async def lifespan(app: FastAPI):
    โ€‹โ€‹    await init_db()
    โ€‹โ€‹    yield
    โ€‹โ€‹    await close_db()
    
    
    โ€‹โ€‹app = FastAPI(
    โ€‹โ€‹    title="Simple API",
    โ€‹โ€‹    description="็ฐกๆ˜“ API",
    โ€‹โ€‹    version="1.0.0",
    โ€‹โ€‹    lifespan=lifespan,
    โ€‹โ€‹)
    
    
    โ€‹โ€‹app.include_router(items_router)
    โ€‹โ€‹app.include_router(users_router)
    
    
  • .env ๅ…ง็š„ DATABASE_URI ๆ”นๆˆ็•ฐๆญฅๅบซ

    โ€‹โ€‹# MySQL
    โ€‹โ€‹MYSQL_DATABASE_URI="mysql+asyncpg://${MYSQL_USER}:${MYSQL_PASSWORD}@${MYSQL_HOST}:${MYSQL_PORT}/${MYSQL_DB}"
    โ€‹โ€‹# PostgreSQL
    โ€‹โ€‹POSTGRES_DATABASE_URI="postgresql+aiomysql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}"
    

Unit Testing

  • pytest
  • pytest-mock
  • pytest-asyncio

OAuth 2.0

  • ไฝฟ็”จ JWT ๆœ€็ฐกๅฏฆไฝœ OAuth 2.0 ๆŽˆๆฌŠๆต็จ‹

    โ€‹โ€‹from datetime import UTC, datetime, timedelta
    โ€‹โ€‹from typing import Annotated, Any, Literal
    
    โ€‹โ€‹from fastapi import Depends, FastAPI, HTTPException
    โ€‹โ€‹from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
    โ€‹โ€‹from jose import jwt
    
    โ€‹โ€‹app = FastAPI()
    
    โ€‹โ€‹# ่‡ชๅ‹•ๅพž request ็š„ authorization header ๆ‹ฟๅ– bearer token
    โ€‹โ€‹OAuth2Token = Annotated[str, Depends(OAuth2PasswordBearer(tokenUrl="login"))]
    โ€‹โ€‹# ่‡ชๅ‹•ๅพž request ๆŠ“ๅ–็™ปๅ…ฅ่ณ‡่จŠ (username ่ˆ‡ password ๆฌ„ไฝ๏ผŒๆญค็‚บ OAuth 2.0 ่ฆๅฎš)
    โ€‹โ€‹LoginForm = Annotated[OAuth2PasswordRequestForm, Depends()]
    
    
    โ€‹โ€‹def generate_token(
    โ€‹โ€‹    payload: dict[str, Any],
    โ€‹โ€‹    secret: str,
    โ€‹โ€‹    *,
    โ€‹โ€‹    usage: Literal["access", "refresh"],
    โ€‹โ€‹):
    โ€‹โ€‹    expire_time_dict = {"access": 30, "refresh": 60}  # ๅนพ็ง’ๅพŒ้ŽๆœŸ
    โ€‹โ€‹    token_expire_time = datetime.now(UTC) + timedelta(seconds=expire_time_dict[usage])
    โ€‹โ€‹    token_payload = {
    โ€‹โ€‹        **payload,
    โ€‹โ€‹        "exp": token_expire_time,  # ๆŒ‡ๅฎš้ŽๆœŸๆ™‚้–“
    โ€‹โ€‹        "usage": usage,  # ๆŒ‡ๅฎš็”จ้€”
    โ€‹โ€‹    }
    โ€‹โ€‹    token = jwt.encode(token_payload, secret)
    โ€‹โ€‹    return token
    
    
    โ€‹โ€‹def verify_user(payload: dict[str, Any]) -> None:
    โ€‹โ€‹    _user_id = payload.get("id")
    โ€‹โ€‹    _email = payload.get("sub")
    โ€‹โ€‹    if _user_id != 5 or _email != "rogelio@example.com":
    โ€‹โ€‹        raise HTTPException(status_code=401, detail="Unauthorized")
    
    
    โ€‹โ€‹# * ็™ปๅ…ฅ่ทฏ็”ฑ๏ผŒ็ฒๅ– token ็”จ
    โ€‹โ€‹@app.post("/login")
    โ€‹โ€‹def login(form_data: LoginForm):
    โ€‹โ€‹    if form_data.username != "RogelioKG" or form_data.password != "123456":
    โ€‹โ€‹        raise HTTPException(status_code=401, detail="Invalid credentials")
    
    โ€‹โ€‹    secret = "my-secret"
    โ€‹โ€‹    payload = {
    โ€‹โ€‹        "sub": "rogelio@example.com",
    โ€‹โ€‹        "id": 5,
    โ€‹โ€‹    }
    
    โ€‹โ€‹    access_token = generate_token(payload, secret, usage="access")
    โ€‹โ€‹    refresh_token = generate_token(payload, secret, usage="refresh")
    
    โ€‹โ€‹    return {"access_token": access_token, "refresh_token": refresh_token}
    
    
    โ€‹โ€‹# * ๅˆทๆ–ฐ่ทฏ็”ฑ๏ผŒ็ฒๅ–ๆ–ฐ็š„ token ็”จ
    โ€‹โ€‹@app.post("/refresh")
    โ€‹โ€‹def refresh(token: OAuth2Token):
    โ€‹โ€‹    secret = "my-secret"
    โ€‹โ€‹    payload = jwt.decode(token, secret)
    โ€‹โ€‹    verify_user(payload)
    โ€‹โ€‹    assert payload.get("usage") == "refresh"  # ! ๅช่ƒฝไฝฟ็”จ refresh_token ไพ† refresh
    
    โ€‹โ€‹    access_token = generate_token(payload, secret, usage="access")
    โ€‹โ€‹    refresh_token = generate_token(payload, secret, usage="refresh")
    
    โ€‹โ€‹    return {"access_token": access_token, "refresh_token": refresh_token}
    
    
    โ€‹โ€‹# * ็งไบบ่ทฏ็”ฑ๏ผŒ็ฒๅ– private resources ็”จ
    โ€‹โ€‹@app.get("/profile")
    โ€‹โ€‹def profile(token: OAuth2Token):
    โ€‹โ€‹    secret = "my-secret"
    โ€‹โ€‹    payload = jwt.decode(token, secret)
    โ€‹โ€‹    verify_user(payload)
    โ€‹โ€‹    assert payload.get("usage") == "access"  # ! ๅช่ƒฝไฝฟ็”จ access_token ไพ† access
    
    โ€‹โ€‹    return {"message": "Welcome back!"}
    
  • OAuth2PasswordBearer

    • ๅ็จฑๆ„็พฉ๏ผšไฝฟ็”จ่€…่ฆไปฅๅฏ†็ขผๆ›ๅ– token
    • ๅ‘ผๅซๆ™‚๏ผŒๅฐฑๆœƒๅพž request ็š„ authorization header ๆ‹ฟๅ– bearer token
      โ€‹โ€‹โ€‹โ€‹# ๅฏฆไฝœ
      โ€‹โ€‹โ€‹โ€‹class OAuth2PasswordBearer(OAuth2):
      โ€‹โ€‹โ€‹โ€‹    ...
      โ€‹โ€‹โ€‹โ€‹    # ๆ‰€ไปฅ็•ถไฝ ไพ่ณดๆณจๅ…ฅๆ™‚๏ผŒๆœ€ๅพŒๅ›žๅ‚ณ็š„ๅ…ถๅฏฆๆ˜ฏๅญ—ไธฒ (token)
      โ€‹โ€‹โ€‹โ€‹    async def __call__(self, request: Request) -> Optional[str]:
      โ€‹โ€‹โ€‹โ€‹        authorization = request.headers.get("Authorization")
      โ€‹โ€‹โ€‹โ€‹        scheme, param = get_authorization_scheme_param(authorization)
      โ€‹โ€‹โ€‹โ€‹        if not authorization or scheme.lower() != "bearer":
      โ€‹โ€‹โ€‹โ€‹            if self.auto_error:
      โ€‹โ€‹โ€‹โ€‹                raise HTTPException(
      โ€‹โ€‹โ€‹โ€‹                    status_code=HTTP_401_UNAUTHORIZED,
      โ€‹โ€‹โ€‹โ€‹                    detail="Not authenticated",
      โ€‹โ€‹โ€‹โ€‹                    headers={"WWW-Authenticate": "Bearer"},
      โ€‹โ€‹โ€‹โ€‹                )
      โ€‹โ€‹โ€‹โ€‹            else:
      โ€‹โ€‹โ€‹โ€‹                return None
      โ€‹โ€‹โ€‹โ€‹        return param
      
  • OAuth2PasswordRequestForm

    • request ๅšดๆ ผ่ฆๆฑ‚๏ผš
      • ไธ€ๅฎš่ฆ็”จ POST
      • body ไธ€ๅฎš่ฆ็”จ x-www-form-urlencoded ๆ ผๅผ
  • PostMan ๆธฌ่ฉฆ

Docker

database

  • ๅŒๆญฅ
    • SQLite: sqlite3
    • MySQL: pymysql
    • Postgers: psycopg2 (่‹ฅ่ฆๅœจ container ไธŠไฝฟ็”จ๏ผŒ้ ˆๆ”น็‚บ psycopg2-binary)
  • ็•ฐๆญฅ
    • SQLite: aiosqlite
    • MySQL: aiomysql
    • Postgers: asyncpg

caution

  • ๅพŒ็ซฏ็š„ HOST ่ฆ่จญๅฎšๆˆ 0.0.0.0 โ€ฆ

  • ่ณ‡ๆ–™ๅบซ URI ็š„ HOST ่ฆ่จญๅฎšๆˆ docker-compose.yml ๅ…งๆŒ‡ๅฎš็š„ services ๅ็จฑ

    ๅฆ‚ไธ‹็ฏ„ไพ‹๏ผŒ็‚บ postgresql-db

    โ€‹โ€‹services:
    โ€‹โ€‹  backend:
    โ€‹โ€‹    ...
    โ€‹โ€‹  postgresql-db:
    โ€‹โ€‹    ...