REST API ๋ง๋ค๊ธฐ
FastAPI๋ก ์์ ํ RESTful API๋ฅผ ์ค๊ณํ๊ณ ๊ตฌํํ๋ ๋ฐฉ๋ฒ์ ๋ฐฐ์๋ด ์๋ค.
REST API๋?โ
REST (Representational State Transfer)๋ ์น ์๋น์ค๋ฅผ ์ค๊ณํ๊ธฐ ์ํ ์ํคํ ์ฒ ์คํ์ผ์ ๋๋ค. RESTful API๋ HTTP ํ๋กํ ์ฝ์ ํ์ฉํ์ฌ ๋ฆฌ์์ค๋ฅผ ๊ด๋ฆฌํฉ๋๋ค.
REST์ ํต์ฌ ์์นโ
- ํด๋ผ์ด์ธํธ-์๋ฒ ๋ถ๋ฆฌ - ๋ ๋ฆฝ์ ์ธ ๊ฐ๋ฐ๊ณผ ํ์ฅ
- ๋ฌด์ํ์ฑ (Stateless) - ๊ฐ ์์ฒญ์ ๋ ๋ฆฝ์
- ์บ์ ๊ฐ๋ฅ - ์๋ต์ ์บ์ ๊ฐ๋ฅ ์ฌ๋ถ ๋ช ์
- ๊ณ์ธตํ ์์คํ - ์ค๊ฐ ์๋ฒ ์ฌ์ฉ ๊ฐ๋ฅ
- ์ธํฐํ์ด์ค ์ผ๊ด์ฑ - ํต์ผ๋ ์ธํฐํ์ด์ค
HTTP ๋ฉ์๋โ
| ๋ฉ์๋ | ์ฉ๋ | ์์ |
|---|---|---|
| GET | ๋ฆฌ์์ค ์กฐํ | GET /users |
| POST | ๋ฆฌ์์ค ์์ฑ | POST /users |
| PUT | ๋ฆฌ์์ค ์ ์ฒด ์์ | PUT /users/1 |
| PATCH | ๋ฆฌ์์ค ๋ถ๋ถ ์์ | PATCH /users/1 |
| DELETE | ๋ฆฌ์์ค ์ญ์ | DELETE /users/1 |
HTTP ์ํ ์ฝ๋โ
์ ์ ํ ์ํ ์ฝ๋๋ฅผ ์ฌ์ฉํ๋ ๊ฒ์ด ์ค์ํฉ๋๋ค.
from fastapi import FastAPI, status
from fastapi.responses import JSONResponse
app = FastAPI()
# 2xx: ์ฑ๊ณต
# 200 OK - ์ผ๋ฐ์ ์ธ ์ฑ๊ณต
@app.get("/items/{item_id}", status_code=status.HTTP_200_OK)
def read_item(item_id: int):
return {"item_id": item_id}
# 201 Created - ๋ฆฌ์์ค ์์ฑ ์ฑ๊ณต
@app.post("/items/", status_code=status.HTTP_201_CREATED)
def create_item(item: dict):
return item
# 204 No Content - ์ฑ๊ณตํ์ง๋ง ๋ฐํํ ๋ด์ฉ ์์
@app.delete("/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_item(item_id: int):
return None
# 4xx: ํด๋ผ์ด์ธํธ ์ค๋ฅ
# 400 Bad Request
@app.post("/invalid/")
def invalid_request():
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={"detail": "Invalid request"}
)
# 401 Unauthorized
@app.get("/protected/")
def protected_route():
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={"detail": "Authentication required"}
)
# 403 Forbidden
@app.get("/admin/")
def admin_only():
return JSONResponse(
status_code=status.HTTP_403_FORBIDDEN,
content={"detail": "Access denied"}
)
# 404 Not Found
@app.get("/items/{item_id}")
def get_item(item_id: int):
return JSONResponse(
status_code=status.HTTP_404_NOT_FOUND,
content={"detail": "Item not found"}
)
# 5xx: ์๋ฒ ์ค๋ฅ
# 500 Internal Server Error
@app.get("/error/")
def server_error():
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={"detail": "Internal server error"}
)
URL ์ค๊ณ ๋ฒ ์คํธ ํ๋ํฐ์คโ
์ข์ URL ์ค๊ณโ
# โ
๋ช
์ฌ ์ฌ์ฉ, ๋ณต์ํ
GET /users # ๋ชจ๋ ์ฌ์ฉ์ ์กฐํ
GET /users/123 # ํน์ ์ฌ์ฉ์ ์กฐํ
POST /users # ์ฌ์ฉ์ ์์ฑ
PUT /users/123 # ์ฌ์ฉ์ ์ ์ฒด ์์
PATCH /users/123 # ์ฌ์ฉ์ ๋ถ๋ถ ์์
DELETE /users/123 # ์ฌ์ฉ์ ์ญ์
# โ
๊ณ์ธต ๊ตฌ์กฐ
GET /users/123/posts # ์ฌ์ฉ์์ ๊ฒ์๊ธ ๋ชฉ๋ก
GET /users/123/posts/456 # ํน์ ๊ฒ์๊ธ
POST /users/123/posts # ๊ฒ์๊ธ ์์ฑ
DELETE /users/123/posts/456 # ๊ฒ์๊ธ ์ญ์
# โ
ํํฐ๋ง, ์ ๋ ฌ, ํ์ด์ง
GET /users?role=admin # ์ญํ ๋ณ ํํฐ
GET /users?sort=created_at&order=desc # ์ ๋ ฌ
GET /users?page=2&limit=10 # ํ์ด์ง
# โ
๊ฒ์
GET /users/search?q=john # ๊ฒ์
# โ ํผํด์ผ ํ ํจํด
GET /getUser/123 # ๋์ฌ ์ฌ์ฉ (X)
POST /createUser # ๋์ฌ ์ฌ์ฉ (X)
GET /user # ๋จ์ํ (X)
GET /users/delete/123 # ๋ฉ์๋๋ฅผ URL์ ํฌํจ (X)
์์ ํ CRUD API ์์ โ
๋ธ๋ก๊ทธ APIโ
from fastapi import FastAPI, HTTPException, Query, Path, status
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime
from enum import Enum
app = FastAPI(
title="Blog API",
description="์์ ํ RESTful ๋ธ๋ก๊ทธ API",
version="1.0.0"
)
# ===== ๋ชจ๋ธ ์ ์ =====
class PostStatus(str, Enum):
draft = "draft"
published = "published"
archived = "archived"
class PostBase(BaseModel):
title: str = Field(..., min_length=1, max_length=200)
content: str = Field(..., min_length=1)
status: PostStatus = PostStatus.draft
tags: List[str] = []
class PostCreate(PostBase):
pass
class PostUpdate(BaseModel):
title: Optional[str] = Field(None, min_length=1, max_length=200)
content: Optional[str] = Field(None, min_length=1)
status: Optional[PostStatus] = None
tags: Optional[List[str]] = None
class Post(PostBase):
id: int
author_id: int
created_at: datetime
updated_at: datetime
view_count: int = 0
class Config:
orm_mode = True
# ===== ์์ ๋ฐ์ดํฐ๋ฒ ์ด์ค =====
posts_db = []
post_counter = 1
# ===== API ์๋ํฌ์ธํธ =====
# 1. ๊ฒ์๊ธ ๋ชฉ๋ก ์กฐํ (ํํฐ๋ง, ์ ๋ ฌ, ํ์ด์ง)
@app.get(
"/posts",
response_model=List[Post],
tags=["posts"],
summary="๊ฒ์๊ธ ๋ชฉ๋ก ์กฐํ"
)
def list_posts(
status: Optional[PostStatus] = Query(None, description="์ํ๋ณ ํํฐ"),
tag: Optional[str] = Query(None, description="ํ๊ทธ๋ณ ํํฐ"),
search: Optional[str] = Query(None, description="์ ๋ชฉ ๊ฒ์"),
sort_by: str = Query("created_at", description="์ ๋ ฌ ๊ธฐ์ค"),
order: str = Query("desc", regex="^(asc|desc)$"),
page: int = Query(1, ge=1, description="ํ์ด์ง ๋ฒํธ"),
limit: int = Query(10, ge=1, le=100, description="ํ์ด์ง๋น ํญ๋ชฉ ์")
):
"""
๊ฒ์๊ธ ๋ชฉ๋ก์ ์กฐํํฉ๋๋ค.
- **status**: ์ํ๋ณ ํํฐ๋ง
- **tag**: ํ๊ทธ๋ณ ํํฐ๋ง
- **search**: ์ ๋ชฉ ๊ฒ์
- **sort_by**: ์ ๋ ฌ ๊ธฐ์ค (created_at, updated_at, view_count)
- **order**: ์ ๋ ฌ ์์ (asc, desc)
- **page**: ํ์ด์ง ๋ฒํธ
- **limit**: ํ์ด์ง๋น ํญ๋ชฉ ์
"""
# ํํฐ๋ง
filtered_posts = posts_db
if status:
filtered_posts = [p for p in filtered_posts if p["status"] == status]
if tag:
filtered_posts = [p for p in filtered_posts if tag in p["tags"]]
if search:
filtered_posts = [
p for p in filtered_posts
if search.lower() in p["title"].lower()
]
# ์ ๋ ฌ
reverse = (order == "desc")
filtered_posts.sort(key=lambda x: x.get(sort_by, 0), reverse=reverse)
# ํ์ด์ง
start = (page - 1) * limit
end = start + limit
return filtered_posts[start:end]
# 2. ํน์ ๊ฒ์๊ธ ์กฐํ
@app.get(
"/posts/{post_id}",
response_model=Post,
tags=["posts"],
summary="๊ฒ์๊ธ ์กฐํ"
)
def get_post(
post_id: int = Path(..., description="๊ฒ์๊ธ ID", ge=1)
):
"""ํน์ ๊ฒ์๊ธ์ ์กฐํํฉ๋๋ค."""
for post in posts_db:
if post["id"] == post_id:
# ์กฐํ์ ์ฆ๊ฐ
post["view_count"] += 1
return post
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Post with id {post_id} not found"
)
# 3. ๊ฒ์๊ธ ์์ฑ
@app.post(
"/posts",
response_model=Post,
status_code=status.HTTP_201_CREATED,
tags=["posts"],
summary="๊ฒ์๊ธ ์์ฑ"
)
def create_post(post: PostCreate, author_id: int = 1):
"""์ ๊ฒ์๊ธ์ ์์ฑํฉ๋๋ค."""
global post_counter
now = datetime.now()
new_post = {
"id": post_counter,
"author_id": author_id,
"title": post.title,
"content": post.content,
"status": post.status,
"tags": post.tags,
"created_at": now,
"updated_at": now,
"view_count": 0
}
posts_db.append(new_post)
post_counter += 1
return new_post
# 4. ๊ฒ์๊ธ ์ ์ฒด ์์ (PUT)
@app.put(
"/posts/{post_id}",
response_model=Post,
tags=["posts"],
summary="๊ฒ์๊ธ ์ ์ฒด ์์ "
)
def update_post_full(post_id: int, post: PostCreate):
"""๊ฒ์๊ธ์ ์ ์ฒด ์์ ํฉ๋๋ค (๋ชจ๋ ํ๋ ํ์)."""
for existing_post in posts_db:
if existing_post["id"] == post_id:
# ์ ์ฒด ๊ต์ฒด
existing_post["title"] = post.title
existing_post["content"] = post.content
existing_post["status"] = post.status
existing_post["tags"] = post.tags
existing_post["updated_at"] = datetime.now()
return existing_post
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Post with id {post_id} not found"
)
# 5. ๊ฒ์๊ธ ๋ถ๋ถ ์์ (PATCH)
@app.patch(
"/posts/{post_id}",
response_model=Post,
tags=["posts"],
summary="๊ฒ์๊ธ ๋ถ๋ถ ์์ "
)
def update_post_partial(post_id: int, post: PostUpdate):
"""๊ฒ์๊ธ์ ๋ถ๋ถ ์์ ํฉ๋๋ค (์ ํ์ ํ๋)."""
for existing_post in posts_db:
if existing_post["id"] == post_id:
# ์ ๊ณต๋ ํ๋๋ง ์
๋ฐ์ดํธ
update_data = post.dict(exclude_unset=True)
for field, value in update_data.items():
existing_post[field] = value
existing_post["updated_at"] = datetime.now()
return existing_post
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Post with id {post_id} not found"
)
# 6. ๊ฒ์๊ธ ์ญ์
@app.delete(
"/posts/{post_id}",
status_code=status.HTTP_204_NO_CONTENT,
tags=["posts"],
summary="๊ฒ์๊ธ ์ญ์ "
)
def delete_post(post_id: int):
"""๊ฒ์๊ธ์ ์ญ์ ํฉ๋๋ค."""
for i, post in enumerate(posts_db):
if post["id"] == post_id:
posts_db.pop(i)
return
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Post with id {post_id} not found"
)
# 7. ๊ฒ์๊ธ ํต๊ณ
@app.get(
"/posts/stats/summary",
tags=["posts", "stats"],
summary="๊ฒ์๊ธ ํต๊ณ"
)
def get_post_stats():
"""๊ฒ์๊ธ ํต๊ณ๋ฅผ ์กฐํํฉ๋๋ค."""
total = len(posts_db)
by_status = {}
total_views = 0
for post in posts_db:
status = post["status"]
by_status[status] = by_status.get(status, 0) + 1
total_views += post["view_count"]
return {
"total_posts": total,
"by_status": by_status,
"total_views": total_views,
"average_views": total_views / total if total > 0 else 0
}
# 8. ๊ฒ์๊ธ ๊ฒ์
@app.get(
"/posts/search",
response_model=List[Post],
tags=["posts"],
summary="๊ฒ์๊ธ ๊ฒ์"
)
def search_posts(
q: str = Query(..., min_length=1, description="๊ฒ์์ด"),
fields: List[str] = Query(["title", "content"], description="๊ฒ์ ํ๋")
):
"""๊ฒ์๊ธ์ ๊ฒ์ํฉ๋๋ค."""
results = []
q_lower = q.lower()
for post in posts_db:
if "title" in fields and q_lower in post["title"].lower():
results.append(post)
elif "content" in fields and q_lower in post["content"].lower():
if post not in results:
results.append(post)
return results
๋ฐ์ดํฐ ๊ฒ์ฆโ
Pydantic์ผ๋ก ๊ฐ๋ ฅํ ๋ฐ์ดํฐ ๊ฒ์ฆ์ ๊ตฌํํ ์ ์์ต๋๋ค.
from pydantic import BaseModel, Field, validator, EmailStr
from typing import Optional
from datetime import datetime
class UserCreate(BaseModel):
username: str = Field(
...,
min_length=3,
max_length=50,
regex="^[a-zA-Z0-9_]+$",
description="์๋ฌธ, ์ซ์, ์ธ๋์ค์ฝ์ด ๋ง ๊ฐ๋ฅ"
)
email: EmailStr
password: str = Field(..., min_length=8)
age: Optional[int] = Field(None, ge=0, le=150)
website: Optional[str] = None
@validator('username')
def username_alphanumeric(cls, v):
"""์ฌ์ฉ์๋ช
์ถ๊ฐ ๊ฒ์ฆ"""
if not v.isalnum() and '_' not in v:
raise ValueError('Username must be alphanumeric')
return v
@validator('password')
def password_strength(cls, v):
"""๋น๋ฐ๋ฒํธ ๊ฐ๋ ๊ฒ์ฆ"""
if not any(char.isdigit() for char in v):
raise ValueError('Password must contain at least one digit')
if not any(char.isupper() for char in v):
raise ValueError('Password must contain at least one uppercase letter')
return v
@validator('website')
def validate_website(cls, v):
"""์น์ฌ์ดํธ URL ๊ฒ์ฆ"""
if v and not v.startswith(('http://', 'https://')):
raise ValueError('Website must start with http:// or https://')
return v
# ์ฌ์ฉ
@app.post("/users/")
def create_user(user: UserCreate):
return user
์๋ฌ ์ฒ๋ฆฌโ
์ผ๊ด๋ ์๋ฌ ์๋ต ํ์์ ์ ๊ณตํฉ๋๋ค.
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
app = FastAPI()
# ์ปค์คํ
์์ธ
class ItemNotFoundError(Exception):
def __init__(self, item_id: int):
self.item_id = item_id
class DuplicateItemError(Exception):
def __init__(self, field: str, value: str):
self.field = field
self.value = value
# ์์ธ ํธ๋ค๋ฌ
@app.exception_handler(ItemNotFoundError)
async def item_not_found_handler(request: Request, exc: ItemNotFoundError):
return JSONResponse(
status_code=404,
content={
"error": "NotFound",
"message": f"Item with id {exc.item_id} not found",
"path": str(request.url)
}
)
@app.exception_handler(DuplicateItemError)
async def duplicate_item_handler(request: Request, exc: DuplicateItemError):
return JSONResponse(
status_code=409,
content={
"error": "Conflict",
"message": f"{exc.field} '{exc.value}' already exists",
"field": exc.field
}
)
# ๊ฒ์ฆ ์๋ฌ ํธ๋ค๋ฌ
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
errors = []
for error in exc.errors():
errors.append({
"field": ".".join(str(x) for x in error["loc"]),
"message": error["msg"],
"type": error["type"]
})
return JSONResponse(
status_code=422,
content={
"error": "ValidationError",
"message": "Request validation failed",
"details": errors
}
)
# ์ผ๋ฐ ์์ธ ํธ๋ค๋ฌ
@app.exception_handler(Exception)
async def general_exception_handler(request: Request, exc: Exception):
return JSONResponse(
status_code=500,
content={
"error": "InternalServerError",
"message": "An unexpected error occurred",
"path": str(request.url)
}
)
์ธ์ฆ๊ณผ ๊ถํโ
JWT ๊ธฐ๋ฐ ์ธ์ฆ์ ๊ตฌํํด๋ด ์๋ค.
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel
from datetime import datetime, timedelta
import jwt
app = FastAPI()
# ์ค์
SECRET_KEY = "your-secret-key" # ์ค์ ๋ก๋ ํ๊ฒฝ ๋ณ์ ์ฌ์ฉ
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
security = HTTPBearer()
# ๋ชจ๋ธ
class User(BaseModel):
username: str
email: str
role: str = "user"
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
# ์์ ์ฌ์ฉ์ DB
users_db = {
"john": {
"username": "john",
"email": "john@example.com",
"password": "hashed_password",
"role": "admin"
}
}
# JWT ํ ํฐ ์์ฑ
def create_access_token(data: dict):
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
# JWT ํ ํฐ ๊ฒ์ฆ
def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)):
token = credentials.credentials
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials"
)
return username
except jwt.ExpiredSignatureError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token has expired"
)
except jwt.JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials"
)
# ํ์ฌ ์ฌ์ฉ์ ๊ฐ์ ธ์ค๊ธฐ
def get_current_user(username: str = Depends(verify_token)):
user = users_db.get(username)
if user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
return User(**user)
# ๊ด๋ฆฌ์ ๊ถํ ํ์ธ
def require_admin(current_user: User = Depends(get_current_user)):
if current_user.role != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required"
)
return current_user
# ๋ก๊ทธ์ธ
@app.post("/auth/login", response_model=Token)
def login(username: str, password: str):
user = users_db.get(username)
if not user or user["password"] != password: # ์ค์ ๋ก๋ ํด์ ๋น๊ต
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password"
)
access_token = create_access_token(data={"sub": username})
return {"access_token": access_token}
# ๋ณดํธ๋ ์๋ํฌ์ธํธ
@app.get("/users/me", response_model=User)
def read_users_me(current_user: User = Depends(get_current_user)):
return current_user
# ๊ด๋ฆฌ์ ์ ์ฉ ์๋ํฌ์ธํธ
@app.get("/admin/users")
def list_all_users(admin: User = Depends(require_admin)):
return list(users_db.values())