Skip to content

FastAPI Best Practice

๐Ÿ”จ๐Ÿ”จ๐Ÿ”จ๐Ÿ”จ๐Ÿ”จ์ˆ˜์ •์ค‘...๐Ÿ”จ๐Ÿ”จ๐Ÿ”จ๐Ÿ”จ๐Ÿ”จ

1. Project Structure. Consistent & predictable

์ผ๊ด€์„ฑ ์žˆ๊ณ  ์˜ˆ์ธก ๊ฐ€๋Šฅํ•œ ํ”„๋กœ์ ํŠธ ๊ตฌ์กฐ

fastapi-project
โ”œโ”€โ”€ alembic/
โ”œโ”€โ”€ src
โ”‚   โ”œโ”€โ”€ auth
โ”‚   โ”‚   โ”œโ”€โ”€ router.py
โ”‚   โ”‚   โ”œโ”€โ”€ schemas.py  # pydantic models
โ”‚   โ”‚   โ”œโ”€โ”€ models.py  # db models
โ”‚   โ”‚   โ”œโ”€โ”€ dependencies.py
โ”‚   โ”‚   โ”œโ”€โ”€ config.py  # local configs
โ”‚   โ”‚   โ”œโ”€โ”€ constants.py
โ”‚   โ”‚   โ”œโ”€โ”€ exceptions.py
โ”‚   โ”‚   โ”œโ”€โ”€ service.py
โ”‚   โ”‚   โ””โ”€โ”€ utils.py
โ”‚   โ”œโ”€โ”€ aws
โ”‚   โ”‚   โ”œโ”€โ”€ client.py  # client model for external service communication
โ”‚   โ”‚   โ”œโ”€โ”€ schemas.py
โ”‚   โ”‚   โ”œโ”€โ”€ config.py
โ”‚   โ”‚   โ”œโ”€โ”€ constants.py
โ”‚   โ”‚   โ”œโ”€โ”€ exceptions.py
โ”‚   โ”‚   โ””โ”€โ”€ utils.py
โ”‚   โ””โ”€โ”€ posts
โ”‚   โ”‚   โ”œโ”€โ”€ router.py
โ”‚   โ”‚   โ”œโ”€โ”€ schemas.py
โ”‚   โ”‚   โ”œโ”€โ”€ models.py
โ”‚   โ”‚   โ”œโ”€โ”€ dependencies.py
โ”‚   โ”‚   โ”œโ”€โ”€ constants.py
โ”‚   โ”‚   โ”œโ”€โ”€ exceptions.py
โ”‚   โ”‚   โ”œโ”€โ”€ service.py
โ”‚   โ”‚   โ””โ”€โ”€ utils.py
โ”‚   โ”œโ”€โ”€ config.py  # global configs
โ”‚   โ”œโ”€โ”€ models.py  # global models
โ”‚   โ”œโ”€โ”€ exceptions.py  # global exceptions
โ”‚   โ”œโ”€โ”€ pagination.py  # global module e.g. pagination
โ”‚   โ”œโ”€โ”€ database.py  # db connection related stuff
โ”‚   โ””โ”€โ”€ main.py
โ”œโ”€โ”€ tests/
โ”‚   โ”œโ”€โ”€ auth
โ”‚   โ”œโ”€โ”€ aws
โ”‚   โ””โ”€โ”€ posts
โ”œโ”€โ”€ templates/
โ”‚   โ””โ”€โ”€ index.html
โ”œโ”€โ”€ requirements
โ”‚   โ”œโ”€โ”€ base.txt
โ”‚   โ”œโ”€โ”€ dev.txt
โ”‚   โ””โ”€โ”€ prod.txt
โ”œโ”€โ”€ .env
โ”œโ”€โ”€ .gitignore
โ”œโ”€โ”€ logging.ini
โ””โ”€โ”€ alembic.ini

  1. src Folder : ๋ชจ๋“  ๋„๋ฉ”์ธ ๋””๋ ‰ํ„ฐ๋ฆฌ ์ €์žฅ

    • src/ : highest level of an app, contains common models, configs, and constants, etc.
    • src/main.py : root of the project, which inits the FastAPI app
  2. ๊ฐ๊ฐ์˜ package๋Š” router, schema, model ๋“ฑ์œผ๋กœ ๊ตฌ์„ฑ

    • router.py : Is a core of each module with all the endpoints
    • schemas.py : For pydantic models
    • models.py : For DB models
    • service.py : Module specific business logic
    • dependencies.py : Router dependencies
    • constants.py : Module specific constants and error codes
    • config.py : e.g. Env vars
    • utils.py : Non-business logic functions (response normalization, data enrichment, ...)
    • exceptions : Module specific exceptions (PostNotFound, InvalidUserData, ...)
  3. ํŒจํ‚ค์ง€์— "๋‹ค๋ฅธ ํŒจํ‚ค์ง€ service/dependency/constant"๊ฐ€ ํ•„์š”ํ•œ ๊ฒฝ์šฐ๋ผ๋ฉด, Import them with an explicit module name

    Example
    from src.auth import constants as auth_constants
    from src.notifications import service as notification_service
    # ๊ฐ ํŒจํ‚ค์ง€์˜ constants module์— Standard ErrorCode๊ฐ€ ์žˆ๋Š” ๊ฒฝ์šฐ
    from src.posts.constants import ErrorCode as PostsErrorCode
    

2. Excessively use Pydantic for data validation

Pydantic์€ ๋ฐ์ดํ„ฐ๋ฅผ ๊ฒ€์ฆํ•˜๊ณ  ๋ณ€ํ™˜ํ•  ์ˆ˜ ์žˆ๋Š” ํ’๋ถ€ํ•œ ๊ธฐ๋Šฅ์„ ์ง€์›ํ•œ๋‹ค

  • Regular features like required & non-required fields with default values
  • Comprehensive data processing tools like regex
  • Enums for limited allowed options
  • Length validation
  • Email validation
  • etc ...
    Example
    from enum import Enum
    from pydantic import AnyUrl, BaseModel, EmailStr, Field, constr
    
    class MusicBand(str, Enum):
       AEROSMITH = "AEROSMITH"
       QUEEN = "QUEEN"
       ACDC = "AC/DC"
    
    class UserBase(BaseModel):
        first_name: str = Field(min_length=1, max_length=128)
        username: constr(regex="^[A-Za-z0-9-_]+$", to_lower=True, strip_whitespace=True)
        email: EmailStr
        age: int = Field(ge=18, default=None)  # must be greater or equal to 18
        favorite_band: MusicBand = None  # only "AEROSMITH", "QUEEN", "AC/DC" values are allowed to be inputted
        website: AnyUrl = None
    

3. Use dependencies for data validation vs DB

Pydantic์€ Client input๋งŒ ๊ฒ€์ฆํ•  ์ˆ˜ ์žˆ๋‹ค

๋”ฐ๋ผ์„œ, ์ „์ž ๋ฉ”์ผ์ด ์ด๋ฏธ ์กด์žฌํ•˜๊ฑฐ๋‚˜ ์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†๋Š” ๋“ฑ๋“ฑ..
๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์ œ์•ฝ์กฐ๊ฑด์— ๋Œ€ํ•œ ๋ฐ์ดํ„ฐ ์œ ํšจ์„ฑ ๊ฒ€์ฆ์€ Dependency๋ฅผ ์‚ฌ์šฉํ•œ๋‹ค

Example
# dependencies.py
async def valid_post_id(post_id: UUID4) -> Mapping:
    post = await service.get_by_id(post_id)
    if not post:
        raise PostNotFound()

    return post


# router.py
@router.get("/posts/{post_id}", response_model=PostResponse)
async def get_post_by_id(post: Mapping = Depends(valid_post_id)):
    return post


@router.put("/posts/{post_id}", response_model=PostResponse)
async def update_post(
    update_data: PostUpdate,
    post: Mapping = Depends(valid_post_id),
):
    updated_post: Mapping = await service.update(id=post["id"], data=update_data)
    return updated_post


@router.get("/posts/{post_id}/reviews", response_model=list[ReviewsResponse])
async def get_post_reviews(post: Mapping = Depends(valid_post_id)):
    post_reviews: list[Mapping] = await reviews_service.get_by_post_id(post["id"])
    return post_reviews

4. Chain dependencies

Dependency๋Š” ๋‹ค๋ฅธ dependency๋ฅผ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๊ณ , ์œ ์‚ฌ logic์— ๋Œ€ํ•œ ์ฝ”๋“œ ๋ฐ˜๋ณต์„ ํ”ผํ•  ์ˆ˜ ์žˆ๋‹ค

Example
# dependencies.py
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt

async def valid_post_id(post_id: UUID4) -> Mapping:
    post = await service.get_by_id(post_id)
    if not post:
        raise PostNotFound()

    return post


async def parse_jwt_data(
    token: str = Depends(OAuth2PasswordBearer(tokenUrl="/auth/token"))
) -> dict:
    try:
        payload = jwt.decode(token, "JWT_SECRET", algorithms=["HS256"])
    except JWTError:
        raise InvalidCredentials()

    return {"user_id": payload["id"]}


async def valid_owned_post(
    post: Mapping = Depends(valid_post_id),
    token_data: dict = Depends(parse_jwt_data),
) -> Mapping:
    if post["creator_id"] != token_data["user_id"]:
        raise UserNotOwner()

    return post

# router.py
@router.get("/users/{user_id}/posts/{post_id}", response_model=PostResponse)
async def get_user_post(post: Mapping = Depends(valid_owned_post)):
    return post

5. Decouple & Reuse dependencies. Dependency calls are cached

Dependency๋Š” ์—ฌ๋Ÿฌ ๋ฒˆ ์žฌ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์ง€๋งŒ, ์žฌ๊ฒ€ํ† ๋˜์ง€๋Š” ์•Š๋Š”๋‹ค
FastAPI๋Š” request ๋ฒ”์œ„ ๋‚ด์—์„œ dependency ๊ฒฐ๊ณผ๋ฅผ cacheํ™” ํ•˜๋Š” ๊ฒƒ์ด default ์„ค์ •์ด๊ธฐ ๋•Œ๋ฌธ์ด๋‹ค

๋งŒ์•ฝ, Service-get_post_by_id๋ฅผ ํ˜ธ์ถœํ•˜๋Š” dependency์˜ ๊ฒฝ์šฐ, ํ•ด๋‹น dependency๋ฅผ ํ˜ธ์ถœํ•  ๋•Œ๋งˆ๋‹ค DB๋ฅผ ๊ฒ€์ƒ‰ํ•˜์ง€ ์•Š๋Š”๋‹ค
์ฒซ๋ฒˆ์งธ ํ˜ธ์ถœ์‹œ์—๋งŒ DB๋ฅผ ๊ฒ€์ƒ‰ํ•œ๋‹ค

์ด๋ฅผ ์•Œ๋ฉด, Multiple smaller function์— ๋Œ€ํ•œ dependency๋ฅผ ์‰ฝ๊ฒŒ ๋ถ„๋ฆฌํ•  ์ˆ˜ ์žˆ๋‹ค

  • valid_owned_post
  • valid_active_creator
  • get_user_post but parse_jwt_data is called only once, in the very first call
Example
# dependencies.py
from fastapi import BackgroundTasks
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt

async def valid_post_id(post_id: UUID4) -> Mapping:
    post = await service.get_by_id(post_id)
    if not post:
        raise PostNotFound()

    return post


async def parse_jwt_data(
    token: str = Depends(OAuth2PasswordBearer(tokenUrl="/auth/token"))
) -> dict:
    try:
        payload = jwt.decode(token, "JWT_SECRET", algorithms=["HS256"])
    except JWTError:
        raise InvalidCredentials()

    return {"user_id": payload["id"]}


async def valid_owned_post(
    post: Mapping = Depends(valid_post_id),
    token_data: dict = Depends(parse_jwt_data),
) -> Mapping:
    if post["creator_id"] != token_data["user_id"]:
        raise UserNotOwner()

    return post


async def valid_active_creator(
    token_data: dict = Depends(parse_jwt_data),
):
    user = await users_service.get_by_id(token_data["user_id"])
    if not user["is_active"]:
        raise UserIsBanned()

    if not user["is_creator"]:
       raise UserNotCreator()

    return user


# router.py
@router.get("/users/{user_id}/posts/{post_id}", response_model=PostResponse)
async def get_user_post(
    worker: BackgroundTasks,
    post: Mapping = Depends(valid_owned_post),
    user: Mapping = Depends(valid_active_creator),
):
    """Get post that belong the active user."""
    worker.add_task(notifications_service.send_email, user["id"])
    return post

6. Follow the REST

Restful API๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ฒฝ๋กœ์—์„œ dependency๋ฅผ ์‰ฝ๊ฒŒ ์žฌ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋‹ค

  • GET /courses/:course_id
  • GET /courses/:course_id/chapters/:chapter_id/lessons
  • GET /chapters/:chapter_id

๋‹จ, ๊ฒฝ๋กœ์— ๋™์ผํ•œ ๋ณ€์ˆ˜ ์ด๋ฆ„์„ ์‚ฌ์šฉํ•ด์•ผ ํ•œ๋‹ค

Example
# src.profiles.dependencies
async def valid_profile_id(profile_id: UUID4) -> Mapping:
    profile = await service.get_by_id(post_id)
    if not profile:
        raise ProfileNotFound()

    return profile

# src.creators.dependencies
async def valid_creator_id(profile: Mapping = Depends(valid_profile_id)) -> Mapping:
    if not profile["is_creator"]:
       raise ProfileNotCreator()

    return profile

# src.profiles.router.py
@router.get("/profiles/{profile_id}", response_model=ProfileResponse)
async def get_user_profile_by_id(profile: Mapping = Depends(valid_profile_id)):
    """Get profile by id."""
    return profile

# src.creators.router.py
@router.get("/creators/{profile_id}", response_model=ProfileResponse)
async def get_user_profile_by_id(
     creator_profile: Mapping = Depends(valid_creator_id)
):
    """Get creator's profile by id."""
    return creator_profile

Use /me endpoints for users resources (GET /profiles/me,GET /users/me/posts)

  • ์‚ฌ์šฉ์ž ID์˜ ์กด์žฌ ์—ฌ๋ถ€๋ฅผ ๊ฒ€์ฆํ•  ํ•„์š” ์—†์Œ - ์ธ์ฆ๋ฐฉ๋ฒ•์„ ํ†ตํ•ด ์ด๋ฏธ ํ™•์ธ
  • ์‚ฌ์šฉ์žID๊ฐ€ ์š”์ฒญ์ž์˜ ๊ฒƒ์ธ์ง€ ํ™•์ธํ•  ํ•„์š” ์—†์Œ

7. Don't make your routes async, if you have only blocking I/O operations

Under the hood, FastAPI๋Š” async&sync I/O ์ž‘์—…์„ ๋ชจ๋‘ ํšจ๊ณผ์ ์œผ๋กœ ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์žˆ๋‹ค.

  • FastAPI runs sync routes in the threadpool and blocking I/O operations won't stop the event loop from executing the tasks.
  • Otherwise, if the route is defined async then it's called regularly via await and FastAPI trusts you to do only non-blocking I/O operations.

The caveat is if you fail that trust and execute blocking operations within async routes, the event loop will not be able to run the next tasks until that blocking operation is done.

Example
import asyncio
import time

@router.get("/terrible-ping")
async def terrible_catastrophic_ping():
    time.sleep(10) # I/O blocking operation for 10 seconds
    pong = service.get_pong()  # I/O blocking operation to get pong from DB

    return {"pong": pong}

@router.get("/good-ping")
def good_ping():
    time.sleep(10) # I/O blocking operation for 10 seconds, but in another thread
    pong = service.get_pong()  # I/O blocking operation to get pong from DB, but in another thread

    return {"pong": pong}

@router.get("/perfect-ping")
async def perfect_ping():
    await asyncio.sleep(10) # non-blocking I/O operation
    pong = await service.async_get_pong()  # non-blocking I/O db call

    return {"pong": pong}

What happens when we call:

  1. GET /terrible-ping
    • FastAPI ์„œ๋ฒ„๊ฐ€ request๋ฅผ ์ˆ˜์‹ ํ•˜๊ณ , ์ฒ˜๋ฆฌ๋ฅผ ์‹œ์ž‘ํ•œ๋‹ค
    • ์„œ๋ฒ„์˜ event loop์™€ queue์˜ ๋ชจ๋“  ์ž‘์—…์ด time.sleep()์ด ๋๋‚ ๋•Œ๊นŒ์ง€ ๋Œ€๊ธฐํ•œ๋‹ค
      • ์„œ๋ฒ„๋Š” time.sleep()์ด I/O ์ž‘์—…์ด ์•„๋‹ˆ๋ผ ์ƒ๊ฐํ•˜๋ฏ€๋กœ, ์™„๋ฃŒ๋  ๋•Œ๊นŒ์ง€ ๊ธฐ๋‹ค๋ฆฐ๋‹ค
      • ์„œ๋ฒ„๋Š” ๋Œ€๊ธฐํ•˜๋Š” ๋™์•ˆ ์ƒˆ๋กœ์šด request๋ฅผ ์ˆ˜๋ฝํ•˜์ง€ ์•Š๋Š”๋‹ค
    • event loop์™€ queue์˜ ๋ชจ๋“  ์ž‘์—…์ด service.get_pong()์ด ์™„๋ฃŒ๋  ๋•Œ๊นŒ์ง€ ๋Œ€๊ธฐํ•œ๋‹ค
      • ์„œ๋ฒ„๋Š” service.get_pong()์ด I/O ์ž‘์—…์ด ์•„๋‹ˆ๋ผ ์ƒ๊ฐํ•˜๋ฏ€๋กœ, ์™„๋ฃŒ๋  ๋•Œ๊นŒ์ง€ ๊ธฐ๋‹ค๋ฆฐ๋‹ค
      • ์„œ๋ฒ„๋Š” ๋Œ€๊ธฐํ•˜๋Š” ๋™์•ˆ ์ƒˆ๋กœ์šด request๋ฅผ ์ˆ˜๋ฝํ•˜์ง€ ์•Š๋Š”๋‹ค
    • Server returns the response
      • After a response, server starts accepting new requests
  2. GET /good-ping
    • FastAPI server receives a request and starts handling it
    • FastAPI sends the whole route good_ping to the threadpool, where a worker thread will run the function
    • good_ping์ด ์‹คํ–‰๋˜๋Š” ๋™์•ˆ event loop๋Š” queue์—์„œ ๋‹ค์Œ ์ž‘์—…์„ ์„ ํƒํ•˜๊ณ  ์ž‘์—…ํ•œ๋‹ค (ex: ์ƒˆ ์š”์ฒญ ์ˆ˜๋ฝ, db ํ˜ธ์ถœ ๋“ฑ)
      • Main thread์™€ ๊ด€๊ณ„์—†์ด worker thread๋Š” time.sleep๊ฐ€ ์™„๋ฃŒ๋  ๋•Œ๊นŒ์ง€ ๊ธฐ๋‹ค๋ฆฌ๊ณ , service.get_pong์„ ์™„๋ฃŒํ•œ๋‹ค
      • Sync operation blocks only the side thread, not the main one
    • When good_ping finishes its work, server returns a response to the client
  3. GET /perfect-ping
    • FastAPI server receives a request and starts handling it
    • FastAPI awaits asyncio.sleep(10)
    • Event loop selects next tasks from the queue and works on them (e.g. accept new request, call db)
    • When asyncio.sleep(10) is done, servers goes to the next lines and awaits service.async_get_pong
    • Event loop selects next tasks from the queue and works on them (e.g. accept new request, call db)
    • When service.async_get_pong is done, server returns a response to the client

โš  Non-blocking awaitables|thread pool๋กœ ์ „์†ก๋˜๋Š” ์ž‘์—…์€ I/O intensive task์ด์–ด์•ผ ํ•œ๋‹ค (open file, db call, external api call ...)

  • CPU-intensive task(heavy calculations, data processing, video transcoding)๋ฅผ ๊ธฐ๋‹ค๋ฆฌ๋Š” ๊ฒƒ์€ ๊ฐ€์น˜๊ฐ€ ์—†๋‹ค. CPU๊ฐ€ ์ž‘์—…์„ ์™„๋ฃŒํ•˜๊ธฐ ์œ„ํ•ด ์ž‘๋™ํ•˜๊ธฐ ๋•Œ๋ฌธ์ด๋‹ค. ๋ฐ˜๋ฉด I/O ์ž‘์—…์€ ์™ธ๋ถ€ ์ž‘์—…์ด๋ฏ€๋กœ ์„œ๋ฒ„๋Š” ํ•ด๋‹น ์ž‘์—…์ด ์™„๋ฃŒ๋  ๋•Œ๊นŒ์ง€ ์•„๋ฌด๊ฒƒ๋„ ํ•˜์ง€ ์•Š์•„๋„ ๋˜๋ฏ€๋กœ ๋‹ค์Œ ์ž‘์—…์œผ๋กœ ๋„˜์–ด๊ฐˆ ์ˆ˜ ์žˆ๋‹ค
  • GIL๋กœ ์ธํ•ด ํ•œ ๋ฒˆ์— ํ•˜๋‚˜์˜ thread๋งŒ ์ž‘๋™ํ•  ์ˆ˜ ์žˆ๋‹ค
  • CPU intensive task๋ฅผ ์ตœ์ ํ™”ํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š”, multi-process๋กœ ๊ฐ€์•ผํ•œ๋‹ค

8. Custom base model from day 0

์ œ์–ด ๊ฐ€๋Šฅํ•œ global base model์„ ์‚ฌ์šฉํ•˜๋ฉด app๋‚ด์˜ ๋ชจ๋“  model์„ ์ปค์Šคํ„ฐ๋งˆ์ด์ง•ํ•  ์ˆ˜ ์žˆ๋‹ค.
์˜ˆ์‹œ๋กœ ํ‘œ์ค€ datetime ํ˜•์‹์„ ๊ฐ–๊ฑฐ๋‚˜, base model ์˜ ๋ชจ๋“  ํ•˜์œ„ํด๋ž˜์Šค์— ๋Œ€ํ•ด super method๋ฅผ ์ถ”๊ฐ€ํ•  ์ˆ˜ ์žˆ๋‹ค.

Example
from datetime import datetime
from zoneinfo import ZoneInfo

import orjson
from fastapi.encoders import jsonable_encoder
from pydantic import BaseModel, root_validator


def orjson_dumps(v, *, default):
    # orjson.dumps returns bytes, to match standard json.dumps we need to decode
    return orjson.dumps(v, default=default).decode()


def convert_datetime_to_gmt(dt: datetime) -> str:
    if not dt.tzinfo:
        dt = dt.replace(tzinfo=ZoneInfo("UTC"))

    return dt.strftime("%Y-%m-%dT%H:%M:%S%z")


class ORJSONModel(BaseModel):
    class Config:
        json_loads = orjson.loads
        json_dumps = orjson_dumps
        json_encoders = {datetime: convert_datetime_to_gmt}  # method for customer JSON encoding of datetime fields

    @root_validator()
    def set_null_microseconds(cls, data: dict) -> dict:
       """Drops microseconds in all the datetime field values."""
        datetime_fields = {
            k: v.replace(microsecond=0)
            for k, v in data.items()
            if isinstance(k, datetime)
        }

        return {**data, **datetime_fields}

    def serializable_dict(self, **kwargs):
       """Return a dict which contains only serializable fields."""
        default_dict = super().dict(**kwargs)

        return jsonable_encoder(default_dict)

In the example above we have decided to make a global base model which:

  • Uses orjson to serialize data
  • Drops microseconds to 0 in all date formats
  • Serializes all datetime fields to standard format with explicit timezone

9. Docs

API๊ฐ€ ๊ณต๊ฐœ๋˜์ง€ ์•Š์€ ๊ฒฝ์šฐ์—๋Š” Docs ๋˜ํ•œ ๊ณต๊ฐœํ•˜์ง€ ์•Š๋Š”๋‹ค.

Example
from fastapi import FastAPI
from starlette.config import Config

config = Config(".env")  # parse .env file for env variables

ENVIRONMENT = config("ENVIRONMENT")  # get current env name
SHOW_DOCS_ENVIRONMENT = ("local", "staging")  # explicit list of allowed envs

app_configs = {"title": "My Cool API"}
if ENVIRONMENT not in SHOW_DOCS_ENVIRONMENT:
   app_configs["openapi_url"] = None  # set url for docs as null

app = FastAPI(**app_configs)

Help FastAPI to generate an easy-to-understand docs

  • Set response_model, status_code, description, ...
  • If models and statuses vary, use responses route attribute to add docs for different responses
Example
from fastapi import APIRouter, status

router = APIRouter()

@router.post(
    "/endpoints",
    response_model=DefaultResponseModel,  # default response pydantic model
    status_code=status.HTTP_201_CREATED,  # default status code
    description="Description of the well documented endpoint",
    tags=["Endpoint Category"],
    summary="Summary of the Endpoint",
    responses={
        status.HTTP_200_OK: {
            "model": OkResponse, # custom pydantic model for 200 response
            "description": "Ok Response",
        },
        status.HTTP_201_CREATED: {
            "model": CreatedResponse,  # custom pydantic model for 201 response
            "description": "Creates something from user request ",
        },
        status.HTTP_202_ACCEPTED: {
            "model": AcceptedResponse,  # custom pydantic model for 202 response
            "description": "Accepts request and handles it later",
        },
    },
)
async def documented_route():
    pass

10. Use Pydantic's BaseSettings for configs

Pydantic์€ ํ™˜๊ฒฝ ๋ณ€์ˆ˜๋ฅผ ๋ถ„์„ํ•˜๊ณ , validators๋กœ ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์žˆ๋Š” ๋„๊ตฌ๋ฅผ ์ œ๊ณตํ•œ๋‹ค.

Example
from pydantic import AnyUrl, BaseSettings, PostgresDsn

class AppSettings(BaseSettings):
    class Config:
        env_file = ".env"
        env_file_encoding = "utf-8"
        env_prefix = "app_"

    DATABASE_URL: PostgresDsn
    IS_GOOD_ENV: bool = True
    ALLOWED_CORS_ORIGINS: set[AnyUrl]

11. SQLAlchemy: Set DB keys naming convention

๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๊ทœ์น™์— ๋”ฐ๋ผ ์ธ๋ฑ์Šค ์ด๋ฆ„์„ ์„ค์ •ํ•˜๋Š” ๊ฒƒ์ด SQLalchemy๋ณด๋‹ค ๋” ์ข‹๋‹ค

Example
from sqlalchemy import MetaData

POSTGRES_INDEXES_NAMING_CONVENTION = {
    "ix": "%(column_0_label)s_idx",
    "uq": "%(table_name)s_%(column_0_name)s_key",
    "ck": "%(table_name)s_%(constraint_name)s_check",
    "fk": "%(table_name)s_%(column_0_name)s_fkey",
    "pk": "%(table_name)s_pkey",
}
metadata = MetaData(naming_convention=POSTGRES_INDEXES_NAMING_CONVENTION)

12. Migrations. Alembic.

  • Migration์€ staticํ•˜๊ณ  revertableํ•˜์—ฌ์•ผ ํ•œ๋‹ค
    • ๋งŒ์•ฝ migration์ด ๋™์ ์œผ๋กœ ์ƒ์„ฑ๋œ ๋ฐ์ดํ„ฐ์— ์˜์กดํ•˜๋Š” ๊ฒฝ์šฐ, ๋™์ ์ธ ๊ฒƒ์ด ๊ตฌ์กฐ๊ฐ€ ์•„๋‹ˆ๋ผ ๋ฐ์ดํ„ฐ ์ž์ฒด์ธ์ง€ ํ™•์ธ
  • descriptive names&slugs๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ migration์„ ์ƒ์„ฑํ•œ๋‹ค. Slug๊ฐ€ ํ•„์š”ํ•˜๋ฉฐ ๋ณ€๊ฒฝ ์‚ฌํ•ญ์„ ์„ค๋ช…ํ•ด์•ผ ํ•œ๋‹ค
  • ์ƒˆ๋กœ์šด migration์— ๋Œ€ํ•ด ์‚ฌ์šฉ์ž๊ฐ€ ์ฝ์„ ์ˆ˜ ์žˆ๋Š” ํŒŒ์ผ ํ…œํ”Œ๋ฆฟ์„ ์„ค์ •ํ•œ๋‹ค
    • *date*_*slug*.py (2022-08-24_post_content_idx.py)
alembic.ini
file_template = %%(year)d-%%(month).2d-%%(day).2d_%%(slug)s

13. Set DB naming convention

  1. lower_case_snake
  2. singular form (post, post_like, user_playlist)
  3. group similar tables with module prefix (payment_account, payment_bill, post, post_like)
  4. stay consistent across tables, but concrete namings are ok
    • use profile_id in all tables, but if some of them need only profiles that are creators, use creator_id
    • use post_id for all abstract tables like post_like, post_view, but use concrete naming in relevant modules like course_idin chapters.course_id
  5. _at suffix for datetime
  6. _date suffix for date

14. Set tests client async from day 0

DB์™€์˜ integration tests๋ฅผ ์ž‘์„ฑํ•˜๋ฉด, ํ–ฅํ›„ event loop error๊ฐ€ ๋ฐœ์ƒํ•  ๊ฐ€๋Šฅ์„ฑ์ด ๋†’๋‹ค. ๋”ฐ๋ผ์„œ async test client๋ฅผ ์ฆ‰์‹œ ์„ค์ •ํ•ด์•ผํ•œ๋‹ค (async-asgi-testclient OR httpx)

Example
import pytest
from async_asgi_testclient import TestClient

from src.main import app  # inited FastAPI app


@pytest.fixture
async def client():
    host, port = "127.0.0.1", "5555"
    scope = {"client": (host, port)}

    async with TestClient(
        app, scope=scope, headers={"X-User-Fingerprint": "Test"}
    ) as client:
        yield client


@pytest.mark.asyncio
async def test_create_post(client: TestClient):
    resp = await client.post("/posts")

    assert resp.status_code == 201

15. BackgroundTasks > asyncio.create_task

BackgroundTasks can effectively run both blocking and non-blocking I/O operations the same way FastAPI handles blocking routes (sync tasks are run in a threadpool, while async tasks are awaited later)

  • Don't lie to the worker and don't mark blocking I/O operations as async
  • Don't use it for heavy CPU intensive tasks
Example
from fastapi import APIRouter, BackgroundTasks
from pydantic import UUID4

from src.notifications import service as notifications_service


router = APIRouter()


@router.post("/users/{user_id}/email")
async def send_user_email(worker: BackgroundTasks, user_id: UUID4):
    """Send email to user"""
    worker.add_task(notifications_service.send_email, user_id)  # send email after responding client
    return {"status": "ok"}

16. Typing is important

17. Save files in chunks

Don't hope your clients will send small files

Example
import aiofiles
from fastapi import UploadFile

DEFAULT_CHUNK_SIZE = 1024 * 1024 * 50  # 50 megabytes

async def save_video(video_file: UploadFile):
   async with aiofiles.open("/file/path/name.mp4", "wb") as f:
     while chunk := await video_file.read(DEFAULT_CHUNK_SIZE):
         await f.write(chunk)

18. Be careful with dynamic pydantic fields

If you have a pydantic field that can accept a union of types, be sure the validator explicitly knows the difference between those types

Example
from pydantic import BaseModel


class Article(BaseModel):
   text: str | None
   extra: str | None


class Video(BaseModel):
   video_id: int
   text: str | None
   extra: str | None


class Post(BaseModel):
   content: Article | Video


post = Post(content={"video_id": 1, "text": "text"})
print(type(post.content))
# OUTPUT: Article
# Article is very inclusive and all fields are optional, allowing any dict to become validSolutions:
Solutions:

  1. Input์ด ํ—ˆ์šฉ๋œ ์œ ํšจํ•œ ํ•„๋“œ๋งŒ ํ—ˆ์šฉํ–ˆ๋Š”์ง€ ํ™•์ธํ•˜๊ณ , ์•Œ์ˆ˜์—†๋Š” ํ•„๋“œ๊ฐ€ ์ œ๊ณต๋œ ๊ฒฝ์šฐ ์˜ค๋ฅ˜๋ฅผ ๋ฐœ์ƒ์‹œํ‚จ๋‹ค

    Example
    from pydantic import BaseModel, Extra
    
    class Article(BaseModel):
       text: str | None
       extra: str | None
    
       class Config:
            extra = Extra.forbid
    
    
    class Video(BaseModel):
       video_id: int
       text: str | None
       extra: str | None
    
       class Config:
            extra = Extra.forbid
    
    
    class Post(BaseModel):
       content: Article | Video
    

  2. Use Pydantic's Smart Union (>v1.9) if fields are simple
    It's a good solution if the fields are simple like int or bool, but it doesn't work for complex fields like classes

    Without Smart Union
    from pydantic import BaseModel
    
    class Post(BaseModel):
       field_1: bool | int
       field_2: int | str
       content: Article | Video
    
    p = Post(field_1=1, field_2="1", content={"video_id": 1})
    print(p.field_1)
    # OUTPUT: True
    print(type(p.field_2))
    # OUTPUT: int
    print(type(p.content))
    # OUTPUT: Article
    
    With Smart Union
    class Post(BaseModel):
       field_1: bool | int
       field_2: int | str
       content: Article | Video
    
       class Config:
          smart_union = True
    
    
    p = Post(field_1=1, field_2="1", content={"video_id": 1})
    print(p.field_1)
    # OUTPUT: 1
    print(type(p.field_2))
    # OUTPUT: str
    print(type(p.content))
    # OUTPUT: Article, because smart_union doesn't work for complex fields like classes
    

  3. Fast Workaround Order field types properly: from the most strict ones to loose ones

    Example
    class Post(BaseModel):
       content: Video | Article
    

19. SQL-first, Pydantic-second

  • ์ผ๋ฐ˜์ ์œผ๋กœ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๋Š” CPython ๋ณด๋‹ค ํ›จ์”ฌ ๋น ๋ฅด๊ณ  ๊ฐœ๋—ํ•˜๊ฒŒ ๋ฐ์ดํ„ฐ๋ฅผ ์ฒ˜๋ฆฌํ•œ๋‹ค
  • ๋ณต์žกํ•œ join๊ณผ ๊ฐ„๋‹จํ•œ ๋ฐ์ดํ„ฐ ์กฐ์ž‘ ๋ชจ๋‘ SQL์„ ์‚ฌ์šฉํ•˜์—ฌ ์ˆ˜ํ–‰ํ•˜๋Š” ๊ฒƒ์ด ์ข‹๋‹ค
  • ์ค‘์ฒฉ๋œ ๊ฐœ์ฒด๊ฐ€ ์žˆ๋Š” ์‘๋‹ต์— ๋Œ€ํ•ด DB์—์„œ json์„ ์ง‘๊ณ„ํ•˜๋Š” ๊ฒƒ์ด ์ข‹๋‹ค
Example
# src.posts.service
from typing import Mapping

from pydantic import UUID4
from sqlalchemy import desc, func, select, text
from sqlalchemy.sql.functions import coalesce

from src.database import databse, posts, profiles, post_review, products

async def get_posts(
    creator_id: UUID4, *, limit: int = 10, offset: int = 0
) -> list[Mapping]:
    select_query = (
        select(
            (
                posts.c.id,
                posts.c.type,
                posts.c.slug,
                posts.c.title,
                func.json_build_object(
                   text("'id', profiles.id"),
                   text("'first_name', profiles.first_name"),
                   text("'last_name', profiles.last_name"),
                   text("'username', profiles.username"),
                ).label("creator"),
            )
        )
        .select_from(posts.join(profiles, posts.c.owner_id == profiles.c.id))
        .where(posts.c.owner_id == creator_id)
        .limit(limit)
        .offset(offset)
        .group_by(
            posts.c.id,
            posts.c.type,
            posts.c.slug,
            posts.c.title,
            profiles.c.id,
            profiles.c.first_name,
            profiles.c.last_name,
            profiles.c.username,
            profiles.c.avatar,
        )
        .order_by(
            desc(coalesce(posts.c.updated_at, posts.c.published_at, posts.c.created_at))
        )
    )

    return await database.fetch_all(select_query)

# src.posts.schemas
import orjson
from enum import Enum

from pydantic import BaseModel, UUID4, validator


class PostType(str, Enum):
    ARTICLE = "ARTICLE"
    COURSE = "COURSE"


class Creator(BaseModel):
    id: UUID4
    first_name: str
    last_name: str
    username: str


class Post(BaseModel):
    id: UUID4
    type: PostType
    slug: str
    title: str
    creator: Creator

    @validator("creator", pre=True)  # before default validation
    def parse_json(cls, creator: str | dict | Creator) -> dict | Creator:
       if isinstance(creator, str):  # i.e. json
          return orjson.loads(creator)

       return creator

# src.posts.router
from fastapi import APIRouter, Depends

router = APIRouter()


@router.get("/creators/{creator_id}/posts", response_model=list[Post])
async def get_creator_posts(creator: Mapping = Depends(valid_creator_id)):
   posts = await service.get_posts(creator["id"])

   return posts

If an aggregated data form DB is a simple JSON, then take a look at Pydantic'sย Jsonfield type, which will load raw JSON first.

Example
from pydantic import BaseModel, Json

class A(BaseModel):
    numbers: Json[list[int]]
    dicts: Json[dict[str, int]]

valid_a = A(numbers="[1, 2, 3]", dicts='{"key": 1000}')  # becomes A(numbers=[1,2,3], dicts={"key": 1000})
invalid_a = A(numbers='["a", "b", "c"]', dicts='{"key": "str instead of int"}')  # raises ValueError

20. Validate hosts, if users can send publicly available URLs

์˜ˆ๋ฅผ ๋“ค์–ด ๋‹ค์Œ๊ณผ ๊ฐ™์€ ํŠน์ • endpoint๊ฐ€ ์žˆ๋‹ค:

  • ์‚ฌ์šฉ์ž๋กœ๋ถ€ํ„ฐ ๋ฏธ๋””์–ด ํŒŒ์ผ ์ˆ˜์‹ 
  • ํ•ด๋‹น ํŒŒ์ผ์— ๋Œ€ํ•ด ๊ณ ์œ ํ•œ url ์ƒ์„ฑ
  • ์‚ฌ์šฉ์ž์—๊ฒŒ url ๋ฐ˜ํ™˜
  • PUT /profiles/me POST/posts ๊ฐ™์€ ๋‹ค๋ฅธ endpoint์—์„œ ์‚ฌ์šฉ
  • ์ด๋Ÿฌํ•œ endpoint๋Š” whitelisted host์˜ ํŒŒ์ผ๋งŒ ํ—ˆ์šฉ
  • ์ด ์ด๋ฆ„๊ณผ ์ผ์น˜ํ•˜๋Š” url์„ ์‚ฌ์šฉํ•˜์—ฌ ํŒŒ์ผ์„ AWS์— ์—…๋กœ๋“œ
  • url ํ˜ธ์ŠคํŠธ๋ฅผ whitelist์— ์ถ”๊ฐ€ํ•˜์ง€ ์•Š์œผ๋ฉด, ์ž˜๋ชป๋œ ์‚ฌ์šฉ์ž๊ฐ€ ์œ„ํ—˜ํ•œ ๋งํฌ๋ฅผ ์—…๋กœ๋“œํ•  ์ˆ˜ ์žˆ์Œ
Example
from pydantic import AnyUrl, BaseModel

ALLOWED_MEDIA_URLS = {"mysite.com", "mysite.org"}

class CompanyMediaUrl(AnyUrl):
    @classmethod
    def validate_host(cls, parts: dict) -> tuple[str, str, str, bool]:
       """Extend pydantic's AnyUrl validation to whitelist URL hosts."""
        host, tld, host_type, rebuild = super().validate_host(parts)
        if host not in ALLOWED_MEDIA_URLS:
            raise ValueError(
                "Forbidden host url. Upload files only to internal services."
            )

        return host, tld, host_type, rebuild


class Profile(BaseModel):
    avatar_url: CompanyMediaUrl  # only whitelisted urls for avatar

21. Raise a ValueError in custom pydantic validators, if schema directly faces the client

It wil return a nice detailed response to users

Example
# src.profiles.schemas
from pydantic import BaseModel, validator

class ProfileCreate(BaseModel):
    username: str

    @validator("username")
    def validate_bad_words(cls, username: str):
        if username  == "me":
            raise ValueError("bad username, choose another")

        return username


# src.profiles.routes
from fastapi import APIRouter

router = APIRouter()


@router.post("/profiles")
async def get_creator_posts(profile_data: ProfileCreate):
   pass

22. Don't forget FastAPI converts Response Pydantic Object to Dict then to an instance of ResponseModel then to Dict then to JSON

Example
from fastapi import FastAPI
from pydantic import BaseModel, root_validator

app = FastAPI()


class ProfileResponse(BaseModel):
    @root_validator
    def debug_usage(cls, data: dict):
        print("created pydantic model")

        return data

    def dict(self, *args, **kwargs):
        print("called dict")
        return super().dict(*args, **kwargs)


@app.get("/", response_model=ProfileResponse)
async def root():
    return ProfileResponse()

23. If you must use sync SDK, then run it in a thread pool.

  • If you must use a library to interact with external services, and it's not async, then make the HTTP calls in an external worker thread
  • For a simple example, we could use our well-known run_in_threadpool from starlette
Example
from fastapi import FastAPI
from fastapi.concurrency import run_in_threadpool
from my_sync_library import SyncAPIClient

app = FastAPI()


@app.get("/")
async def call_my_sync_library():
    my_data = await service.get_my_data()

    client = SyncAPIClient()
    await run_in_threadpool(client.make_request, data=my_data)

24. Use linters (black, isort, autoflake)

  • With linters, you can forget about formatting the code and focus on writing the business logic
  • Black is the uncompromising code formatter that eliminates so many small decisions you have to make during development. Other linters help you write cleaner code and follow the PEP8
  • It's a popular good practice to use pre-commit hooks, but just using the script was ok for us
#!/bin/sh -e
set -x

autoflake --remove-all-unused-imports --recursive --remove-unused-variables --in-place src tests --exclude=__init__.py
isort src tests --profile black
black src tests

Quote