FastAPI를 배워보자 3일차 - 응답 모델, HTTPException

0

fastapi

목록 보기
3/13

응답 모델과 오류 처리

response model은 API route 경로가 반환하는 데이터의 template 역할을 하며, 서버에 전달된 요청을 기준으로 적절한 응답을 렌더링하기 위해 pydantic을 사용한다.

오류 처리는 application에서 발생하는 오류를 처리하는 로직과 방법을 의미한다. 오류 처리에는 적절한 오류 상태 코드와 오류 메시지가 포함된다.

FastAPI response

응답은 HTTP 메서드를 통해 API와 상호작용하며, API로부터 받은 결과를 가리킨다. 응답은 header와 body로 이루어져있으며, http status code를 포함한다.

응답 헤더

응답 헤더는 요청 상태 및 응답 바디 전달을 안내하는 정보로 구성된다. 대표적으로 Content-Type이 있으며 콘텐츠 유형이 무엇인지 클라이언트에게 알려주는 역할을 한다.

응답 바디

응답 바디는 서버가 클라이언트에게 반환하는 데이터이다. Content-Type 헤더에 의해 응답 바디의 형식이 결정되며 대표적인 예로 application/json이 있다.

상태 코드

상태 코드는 서버가 반환한 응답에 포함되는 짧은 고유 코드이다. 클라이언트가 보낸 요청에 대한 응답 상태를 보내는 것인데, 숫별로 다음의 의미를 가진다.

  1. 1xx: 요청을 받음
  2. 2xx: 요청을 성공적으로 처리
  3. 3xx: 요청을 리다이렉트
  4. 4xx: 클라이언트 오류
  5. 5xx: 서버 측의 오류

상태 코드의 첫 번째 숫자는 상태 그룹을 의미한다. 200은 요청이 정상적으로 성공했다는 것을 의미하고, 400은 클라이언트의 요청이 잘못되었다는 것을, 500은 서버측의 문제가 발생하여 요청을 처리하지 못했다는 것을 의미한다.

이전 코드

이전에 사용했던 코드를 정리하면 다음과 같다.

  • api.py
from fastapi import FastAPI
from todo import todo_router

app = FastAPI()

@app.get('/')
async def welcome() -> dict:
    return {"message": "Hello World"}

app.include_router(router=todo_router)
  • api.py
from fastapi import APIRouter, Path
from model import Todo, TodoItem

todo_router = APIRouter()

todo_list = []

@todo_router.post("/todo")
async def add_todo(todo: Todo) -> dict:
    todo_list.append(todo)
    return {"message": "Todo added successfully"}

@todo_router.get("/todo")
async def retrieve_todos() -> dict:
    return {"todos": todo_list}

@todo_router.get("/todo/{todo_id}")
async def get_single_todo(todo_id: int = Path(..., title="The ID of the todo to retrieve.")) -> dict:
    for todo in todo_list:
        if todo.id == todo_id:
            return {
                "todo": todo
            }
    return {
        "message": "Todo with supplied ID dosen't exist."
    }

@todo_router.put("/todo/{todo_id}")
async def update_todo(todo_data: TodoItem, todo_id: int = Path(..., title="The Id of the todo to be updated")) -> dict:
    for todo in todo_list:
        if todo.id == todo_id:
            todo.item = todo_data.item
            return {
                "message": "Todo updated successfully"
            }
    return {
        "message": "Todo with supplied ID doesn't exist."
    }
    
@todo_router.delete('/todo/{todo_id}')
async def delete_single_todo(todo_id: int) -> dict:
    for index in range(len(todo_list)):
        todo = todo_list[index]
        if todo.id == todo_id:
            todo_list.pop(index)
            return {
                "message": "Todo deleted successfully"
            }
    return {
        "message": "Todo with supplied ID dosen't exist"
    }
    
@todo_router.delete("/todo")
async def delete_all_todo() -> dict:
    todo_list.clear()
    return {
        "message": "Todos deleted successfully"
    }
  • model.py
from pydantic import BaseModel

class Todo(BaseModel):
    id: int
    item: str
    
    class Config:
        schema_extra = {
            "example": {
                "id": 1,
                "item": "Example Schema!"
            }
        }
        
class TodoItem(BaseModel):
    item: str
    
    class Config:
        schema_extra = {
            "example": {
                "item": "Read the next chapter of the book"
            }
        }

응답 모델 작성

응답 모델도 pydantic을 사용해 작성하지만 목적이 다르다.

다음의 /todo 경로의 요청은 todo_list를 불러와준다.

@todo_router.get("/todo")
async def retrieve_todos() -> dict:
    return {"todos": todo_list}

이 route는 todo 배열에 있는 모든 값을 반환한다. TodoItems라는 model을 만들어 response model로 두도록 하자.

모든 todo를 추출해서 배열로 반환하는 라우트를 ID없이 todo 아이템만 반환하도록 변경해보자.

먼저 model.py에 다음과 같이 새로운 모델을 추가하자.

  • model.py
from pydantic import BaseModel
from typing import List

...
        
class TodoItems(BaseModel):
    todos: List[TodoItem]
    
    class Config:
        schema_extra = {
            "example": {
                "todos": [
                    {
                        "item": "Example schema1"
                    },
                    {
                        "item": "Example schema2"
                    }
                ]
            }
        }

TodoItems라는 새로운 모델을 정의해서 TodoItem모델에 정의된 변수 목록을 반환한다. todo.py에 있는 라우트에 다음과 같이 응답 모델을 추가해보도록하자.

  • todo.py
...
from fastapi import APIRouter, Path
from model import Todo, TodoItem, TodoItems

...

@todo_router.get("/todo", response_model=TodoItems)
async def retrieve_todos() -> dict:
    return {"todos": todo_list}

...

@todo_router.get부분에 response_modelTodoItems가 추가된 것을 확인하도록 하자.

이제 어플리케이션을 실행해보도록 하자.

uvicorn api:app --host=127.0.0.1 --port=8080 --reload

어플리케이션이 실행되었다면 todo를 추가해주도록 하자.

curl -X 'POST' 'localhost:8080/todo' \
-H 'accept: application/json' \
-H 'Content-Type: application/json' \
-d '{
    "id": 1, 
    "item": "This todo will be retrieved without exposing my ID!"
}'

이제 todo list를 가져와 보자.

curl localhost:8080/todo 

{"todos":[{"item":"This todo will be retrieved without exposing my ID!"}]}

잘 도착하는 것을 볼 수 있다.

만약 응답 모델을 따르지 않으면 어떻게되는가?? fastapi에서는 이를 오류로 보고 있어서 서버측 에러를 반환한다.

  • api.py
...
@todo_router.get("/todo", response_model=TodoItems)
async def retrieve_todos() -> dict:
    return {"fake": "fake"}
...

다음과 같이 의도적으로 응답 모델과 다른 응답을 전달하려고 한다면 요청 시에 다음과 같은 에러가 발생한다.

curl localhost:8080/todo -v
*   Trying 127.0.0.1:8080...
* Connected to localhost (127.0.0.1) port 8080 (#0)
> GET /todo HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.79.1
> Accept: */*
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 500 Internal Server Error
< date: Wed, 31 May 2023 15:11:33 GMT
< server: uvicorn
< content-length: 21
< content-type: text/plain; charset=utf-8
< 
* Connection #0 to host localhost left intact
Internal Server Error%           

Internal Server Error와 500 status code가 온 것을 확인할 수 있다.

오류 처리

클라이언트에게 오류 로그를 응답으로 전달하거나, Exception 내용을 전달하는 것은 좋은 응답이아니다.

존재하지 않는 리소스나 없는 페이지에 접근하는 경우 요청 시 오류가 발생하며 서버 자체에서 오류가 발생하기도 한다. FastAPI에서 오류는 FastAPIHTTPException 클래스를 사용해 exception을 발생시켜 처리한다.

HTTPException 클래스는 다음 3가지 인수를 받는다.

  1. status_code: 예외 처리 시 반환할 상태 코드
  2. detail: 클라이언트에게 전달할 메서지
  3. headers: 헤더를 요구하는 응답을 위한 선택적 인수

현재 path parameter로 todo 라우트에 대해 요청할 때 todo_id가 todo list에 없는 경우를 요청하면 message를 반환한다.

@todo_router.get("/todo/{todo_id}")
async def get_single_todo(todo_id: int = Path(..., title="The ID of the todo to retrieve.")) -> dict:
    for todo in todo_list:
        if todo.id == todo_id:
            return {
                "todo": todo
            }
    return {
        "message": "Todo with supplied ID dosen't exist."
    }

이렇게 되면 status code도 200이 전달되기 때문에 올바른 응답이 아니다.

이 경우 HTTPException을 사용해 예외를 발생시키도록 수정해보자. HTTPException을 사용하면 적절한 오류 코드를 응답에 포함시킬 수 있다.

  • todo.py
from fastapi import APIRouter, Path, HTTPException, status

...

@todo_router.get("/todo/{todo_id}")
async def get_single_todo(todo_id: int = Path(..., title="The ID of the todo to retrieve.")) -> dict:
    for todo in todo_list:
        if todo.id == todo_id:
            return {
                "todo": todo
            }
    raise HTTPException(
        status_code=status.HTTP_404_NOT_FOUND,
        detail="Totd with supplied ID doesn't exist"
    )

@todo_router.put("/todo/{todo_id}")
async def update_todo(todo_data: TodoItem, todo_id: int = Path(..., title="The Id of the todo to be updated")) -> dict:
    for todo in todo_list:
        if todo.id == todo_id:
            todo.item = todo_data.item
            return {
                "message": "Todo updated successfully"
            }
    raise HTTPException(
        status_code=status.HTTP_404_NOT_FOUND,
        detail="Totd with supplied ID doesn't exist"
    )
    
@todo_router.delete('/todo/{todo_id}')
async def delete_single_todo(todo_id: int) -> dict:
    for index in range(len(todo_list)):
        todo = todo_list[index]
        if todo.id == todo_id:
            todo_list.pop(index)
            return {
                "message": "Todo deleted successfully"
            }
    raise HTTPException(
        status_code=status.HTTP_404_NOT_FOUND,
        detail="Totd with supplied ID doesn't exist"
    )
    
@todo_router.delete("/todo")
async def delete_all_todo() -> dict:
    todo_list.clear()
    return {
        "message": "Todos deleted successfully"
    }

이제 존재하지 않는 todo id를 요청하면 어떤 응답이 전달되는 지 확인해보도록 하자.

curl localhost:8080/todo/2 -v

*   Trying 127.0.0.1:8080...
* Connected to localhost (127.0.0.1) port 8080 (#0)
> GET /todo/2 HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.79.1
> Accept: */*
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 404 Not Found
< date: Wed, 31 May 2023 15:30:15 GMT
< server: uvicorn
< content-length: 48
< content-type: application/json
< 
* Connection #0 to host localhost left intact
{"detail":"Totd with supplied ID doesn't exist"}

404 Not Found status code아 detail 정보가 제대로 전달된 것을 볼 수 있다.

마지막으로 /todo에서 post로 요청할 때, 성공 시 응답 코드를 201 Created로 전달하도록 변경해보자. 기본적으로 fastapi는 응답으로 200을 반환하기 때문에 수정해주어야 한다.

  • todo.py
...
@todo_router.post("/todo", status_code=status.HTTP_201_CREATED)
async def add_todo(todo: Todo) -> dict:
    todo_list.append(todo)
    return {"message": "Todo added successfully"}
...

post요청을 보내보도록 하자.

 curl -X 'POST' 'localhost:8080/todo' \
-H 'accept: application/json' \
-H 'Content-Type: application/json' \
-d '{
    "id": 2, 
    "item": "hello world!"
}' -v

*   Trying 127.0.0.1:8080...
* Connected to localhost (127.0.0.1) port 8080 (#0)
> POST /todo HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.79.1
> accept: application/json
> Content-Type: application/json
> Content-Length: 44
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 201 Created
< date: Wed, 31 May 2023 15:34:46 GMT
< server: uvicorn
< content-length: 37
< content-type: application/json
< 
* Connection #0 to host localhost left intact
{"message":"Todo added successfully"

201 Created응답이 잘 전달된 것을 확인할 수 있다.

이 처럼 APIRouter의 데코레이터 부분을 통해서 기본적으로 응답에 성공했을 때의 status code를 설정할 수 있으며, HTTPException으로 http에 관련된 에러가 발생했을 때 오류와 함께 status code를 전달할 수 있다.

0개의 댓글

관련 채용 정보