복사되었습니다.

FastAPI로 다양한 input을 받는 웹 서버 개발 방법 - Path parameter, query parameter, request body

Cover Image for FastAPI로 다양한 input을 받는 웹 서버 개발 방법 - Path parameter, query parameter, request body

Python의 FastAPI는 간단한 코드로 REST API를 빠르게 개발하도록 해준다. 복잡한 코드 없이 FastAPI로 path parameter, query parameter, JSON 타입의 request body 등 다양한 종류의 input을 받는 python backend 서버를 개발해보자.

FastAPI 설치 및 개발 준비

FastAPI 설치

pip install fastapi "uvicorn[standard]"

FastAPI 뼈대 만들기

main.py 파일에 다음과 같이 코드를 작성하자.

from fastapi import FastAPI
from typing import Dict, Any

app = FastAPI()

@app.get("/")
def welcome() -> Dict[str, Any]:
  return {"Hello": "World"}

FastAPI 실행 및 테스트

서버를 시작하려면 main.py 위치에서 다음 명령어를 실행하면 된다.

uvicorn main:app --reload

브라우저로 다음 주소에 접속해보면 서버가 정상적으로 실행되었는지 확인할 수 있다.

http://localhost:8000

개발된 REST API 명세를 확인하고 싶다면 다음 주소 중 하나에 접속해보자.

  • http://localhost:8000/docs
  • http://localhost:8000/redoc

이 글에서는 주로 첫 번째 주소에 해당하는 swagger 문서를 기준으로 설명을 진행할 것이다.

Path parameter

클라이언트: URL path를 통한 값 전달

Path parameter는 가장 단순한 input 종류이다. 이름 그대로 URL path에 담아서 전달하는 parameter를 뜻한다. 클라이언트 측에서는 다음과 같은 패턴의 URL을 통해 서버에 값을 전달한다.

http://localhost:8000/hello/{param1}/world/{param2}/...

브라우저에 직접 주소를 입력하거나, 다음과 같이 python 코드로 값을 담아서 보낼 수 있다.

import requests

response = requests.get("http://localhost:8000/hello/my-value/world/3.14")

위 예시에서는 param1에 my-value라는 문자열이, 그리고 param2에는 3.14라는 실수 값이 전달된다.

서버 측 path parameter 정의

FastAPI 메서드는 main.py에 다음과 같이 정의하면 된다.

@app.get("/path-params1/{param1}/{param2}/test/{param3}")
async def test_path_params1(
  param1: str,
  param2: int,
  param3: float = 0.3,  # meaningless
) -> Dict[str, Any]:
  return {"param1": param1, "param2": param2, "param3": param3}

Decorator에 전달하는 path에 중괄호로 path parameter들을 포함시키고, 메서드에 같은 이름의 인자들을 받도록 정의하면 된다. 이때, 순서는 중요하지 않고 parameter 이름의 일치 여부만 중요하다. 그리고 param3처럼 default 값을 정의할 수도 있지만, 실제로는 의미가 없다. URL에 무조건 값이 포함되어 있을 것이기 때문이다. 따라서, 혼동을 피하기 위해 path parameter에는 default 값 지정을 하지 않도록 주의하자.

만약 문서화에 힘을 주고 싶다면, Path 클래스를 통해 각 parameter에 설명을 붙일 수도 있다.

from fastapi import Path

@app.get("/path-params2/{param1}/{param2}/test/{param3}")
async def test_path_params2(
  param1: str = Path(),
  param2: int = Path(description="second parameter"),
  param3: float = Path(default=0.3),  # meaningless
) -> Dict[str, Any]:
  return {"param1": param1, "param2": param2, "param3": param3}

http://localhost:8000/docs에 접속하여 swagger 문서를 보면, 다음과 같이 parameter에 설명이 붙는 것을 볼 수 있다.

fastapi verbose path parameters

Pydantic schema로 path parameter 정의

코드가 점점 많아지기 시작하면, input과 output의 형식(이하 schema)을 명시적으로 정의해두어야 가독성이 높아지고 중복 코드가 줄어든다. 이를 위해서는 pydantic을 사용하면 된다. Pydantic을 통해 path parameter들을 정의하는 방법은 다음과 같다.

from fastapi import Depends
from pydantic import BaseModel

class PathParams(BaseModel):
  param1: str
  param2: int
  param3: float

@app.get("/path-params3/{param1}/{param2}/test/{param3}")
async def test_path_params3(
  path_params: PathParams = Depends(),
) -> Dict[str, Any]:
  return {
    "param1": path_params.param1,
    "param2": path_params.param2,
    "param3": path_params.param3
  }

먼저 BaseModel을 상속하는 input schema를 정의한 뒤, 메서드에서 해당 클래스 타입을 인자로 받도록 한다. 한 가지 주의할 점은, 반드시 Depends()를 인자에 넘겨줘야 한다는 것이다. 그렇지 않으면 path parameter 방식으로 정의되지 않는다.

참고로 pydantic schema에도 Path 클래스를 이용할 수 있지만, description을 정의하더라도 swagger UI에는 나타나지 않는다.

class PathParams(BaseModel):
  param1: str
  param2: int = Path(description="test")  # meaningless
  param3: float

Path라는 클래스는 pydantic이 아니라 fastapi 패키지에 포함된 녀석이라 그런 듯 하다. 미래 삽질 방지를 위해 버그가 아니라는 것만 알아두고 넘어가자.

Query parameter

클라이언트: URL에 key와 value 전달

Query parameter도 path parameter와 마찬가지로 URL에 담아서 전달하는 parameter다. 한 가지 차이점은 URL 끝에 물음표를 붙인 뒤 key, value 쌍으로 전달해줘야 한다는 것이다. 클라이언트 측의 예시는 다음과 같다.

http://localhost:8000/items?key=value

여러 개의 값을 전달하려면, 다음과 같이 &을 사용하면 된다.

http://localhost:8000/items?key1=value1&key2=value2

Python에서는 params라는 인자를 사용해서 dictionary 형식으로 좀 더 편리하게 값을 전달할 수 있다.

data = {"key1": "value1", "key2": "value2"}

# response = requests.get("https://example.com/items?key1=value1&key2=value2")
response = requests.get("http://localhost:8000/items", params=data)

print(response.url)
'http://localhost:8000/items?key1=value1&key2=value2'

서버 측 query parameter 정의

FastAPI로 query parameter를 정의하는 방법은 간단하다. 앞서 설명한 path parameter 방식에서 decorator의 path 부분에 중괄호로 인자를 포함하는 부분만 생략하면 된다. 나머지 내용은 path parameter 방식과 거의 유사하다.

사소한 차이점은 default 값을 지정할 수 있다는 것과, 문서화를 위해 Path 대신 Query 클래스를 사용한다는 것이다. 한 가지 재미있는 사실은 Query 클래스를 써도 해당 parameter가 path에 포함되어 있으면 path parameter로 정의된다는 것이다. 다음의 예시를 살펴보자.

from fastapi import Query

@app.get("/query-params/{param2}")
async def test_query_params1(
  param1: str,
  param2: int = Query(description="second parameter"),  # meaningless
  param3: float = Query(default=0.3),
) -> Dict[str, Any]:
  return {"param1": param1, "param2": param2, "param3": param3}

fastapi verbose query parameters

param2Query 클래스를 통해 정의되었지만, path parameter로 인식되는 것을 확인할 수 있다.

Pydantic schema로 query parameter 정의

Pydantic schema를 활용하기 위해서 path parameter와 마찬가지로 메서드 인자에 Depends()를 붙여서 정의해주면 된다. 이미 path parameter 설명에서 살펴봤으므로 예시 코드만 공유하고 넘어가겠다.

class QueryParams(BaseModel):
  param1: str
  param2: int = Query(description="test")
  param3: float = Query(default=0.3)

@app.get("/query-params2")  # no {param}
async def test_query_params2(
  query_params: QueryParams = Depends(),
) -> Dict[str, Any]:
  return {
    "param1": query_params.param1,
    "param2": query_params.param2,
    "param3": query_params.param3,
  }

JSON request body

클라이언트: payload에 담아 값 전달

포함시켜야 하는 값이 많을 때, path parameter나 query parameter를 사용한다면 URL 길이가 지나치게 길어질 수 있다. 이런 경우 request body를 사용해 값을 주고받으면 편리하다. JSON request body는 앞선 두 input 방식과는 달리 URL에 값을 포함하지 않는다. Payload에 JSON 형식으로 데이터를 실어서 보내는 방식이다.

클라이언트 측에서는 python 코드로 다음과 같이 dictionary 타입의 값을 json 인자에 전달하면 된다.

data = {
  "id": 1,
  "value": 3.14,
}

response = requests.post("http://localhost:8000/items", json=data)

참고로, JSON 형식이 아닌 데이터는 json 대신 data 인자에 값을 전달하면 된다. 이에 대한 자세한 내용은 다른 글에서 살펴보겠다.

Pydantic schema로 서버 측 request body 정의

서버 측에서 request body를 받도록 하려면, 앞서 query parameter 쪽에서 살펴본 pydantic schema로 정의하는 방식을 활용하면 된다. 한 가지 차이점은 메서드 인자에 pydantic schema type을 지정할 때 Depends()를 생략해야 한다는 것이다. Depends() 없이 메서드 인자가 정의되어야 request body로 인식된다.

from typing import Optional

class RequestBody(BaseModel):
  param1: str
  hello: Optional[str] = None
  param2: int = 3
  param3: float

@app.post("/request-body/{param1}")  # no {param}
async def test_request_body(
  request_body: RequestBody,  # no Depends()
) -> Dict[str, Any]:
  return {
    "param1": request_body.param1,
    "hello": request_body.hello,
    "param2": request_body.param2,
    "param3": request_body.param3,
  }

위 코드는 대부분 query parameter 예제와 같지만, 메서드의 인자 부분이 request_body: RequestBody = Depends()가 아니라는 점에 주목하자.

코드에서 이상한 점을 발견했는가? Decorater의 path 부분에 path parameter로 param1이 포함되어 있다. 이는 잘못된 것이다. Request body에 포함해야 할 값으로 의도했다면 URL에서 제거해야 한다.

RequestBody 클래스에 정의된 parameter에 helloparam2처럼 값을 등호로 지정해주면 default 값으로 설정된다. 해당 parameter들은 클라이언트 측에서 request body에 반드시 포함해야 하는 목록에서 제외된다.

이렇게 정의를 하고 swagger 문서를 확인해보면, 다음과 같이 RequestBody 클래스에 정의된 모든 parameter들이 request body에 담아서 보내야 할 데이터로 잘 정의된 것을 볼 수 있다.

fastapi json response body example

눈여겨볼 것은 앞서 언급한 param1이 path parameter가 아니라 response body에 들어갈 값으로 인식된다는 점이다. Query parameter 부분에서 설명했던 것과는 다른 결과를 발생시킨다. 따라서, URL에 포함된 {param1}은 의미없는 값이 된다. 클라이언트에서 요청을 날릴 때, {param1} 자리에 어떤 값을 넣더라도 실제 request body의 param1값에는 영향을 주지 않는다. 그렇다고 해서 아예 URL에서 생략하여 http://localhost:8000/request-body로 요청을 날리면 안된다. 주소에 해당하는 페이지를 찾을 수 없다는 404 Not Found 에러가 발생한다. 따라서, 혼동을 피하기 위해 request_body로 받을 값들은 decorator path에서 반드시 빼주도록 하자.

Pydantic schema로 정의된 request body들은 swagger 문서에서 별도 section에서 한꺼번에 확인할 수 있다. 클라이언트 측 코드를 작성할 때 이 section을 참고하자.

fastapi request body pydantic schema

Request body를 GET 요청에서 사용해도 되나?

위 코드 예시에서는 앞선 두 방식과 달리, @app.get 대신 @app.post 메서드, 즉, POST 메서드로 request body 방식의 API를 정의했다. GET 요청에 request body를 실어서 보내는 것은 표준이 아니기 때문이다. RFC 문서에서는 GET 메서드를 다음과 같이 정의하고 있다.

The GET method means retrieve whatever information (in the form of an entity) is identified by the Request-URI.

쉽게 말해서, URL에 얻고자 하는 데이터들을 담으라는 것이다. GET 메서드에 request body를 담지 못하도록 엄격하게 금지하고 있진 않지만, 표준이 아니기 때문에 예상치 못한 오류들을 만날 수도 있다. 따라서, request body 방식을 GET 메서드에서 사용하는 것은 지양하도록 하자. RFC 문서에서도 다음과 같이 경고하고 있다.

A payload within a GET request message has no defined semantics; sending a payload body on a GET request might cause some existing implementations to reject the request.

실습: 간단한 REST API 서버 개발하기

실습 문제 요구사항

FastAPI와 pydantic schema를 활용해서 다음 요구 조건을 만족하는 API를 만들어보자.

  • API 리소스 이름은 "test"로 정한다.
  • POST 메서드로 구현한다.
  • "param1": path parameter로 int를 받는다.
  • "param2": query parameter로 str를 받는다.
  • "param3": request body로 float을 받는다.

클라이언트 측 테스트 코드는 다음과 같다.

>>> import requests
>>> params = {"param2": "Hello, World!"}
>>> request_body = {"param3": 3.14}
>>> 
>>> response = requests.post("http://localhost:8000/test/365", params=params, json=request_body)
>>> response.text
'{"param1":365,"param2":"Hello, World!","param3":3.14}'

웹 서버 코드 답안

서버 측 코드는 다음과 같이 개발하면 된다.

from fastapi import FastAPI, Depends
from typing import Dict, Any
from pydantic import BaseModel

app = FastAPI()

class Schema1(BaseModel):
  param1: int
  param2: str

class Schema2(BaseModel):
  param3: float

@app.post("/test/{param1}")
async def test(
  request_body: Schema2,
  params: Schema1 = Depends(),
) -> Dict[str, Any]:
  return {
    "param1": params.param1,
    "param2": params.param2,
    "param3": request_body.param3,
  }

fastapi getting various inputs exercise answer

마치며

요약 정리

Pydantic schema를 사용한다는 가정하에 요약해보자.

InputDecorator pathDepends()
Path parameterOO
Query parameterXO
Request bodyXX
  • Path parameter와 query parameter를 정의하는 방법은 decorator의 path에 .../{param}/... 형식 문자열의 포함 여부에 따라 다르다.
  • Pydantic schema로 정의할 때는 query parameter와 path parameter 둘 다 Depends()를 붙여줘야 한다.
  • JSON request body는 pydantic schema로 정의하며, Depends()를 붙이지 않는다.

추가로 알아볼 FastAPI의 input 방식들

이번 글에서는 가장 기본적인 세 가지 input 방식에 대해서 알아봤다. 이외에도 파일을 전송받거나, frontend를 통해 form 데이터를 받는 등 여러 input 방식들이 남아있다. 분량 조절을 위해 추가적인 내용은 다른 글에서 자세히 살펴보도록 하겠다.

Comments

    More Posts

    Python으로 kubernetes 환경에 app 배포하기 - Kubernetes deployment with python

    Kubectl 명령어를 통해 kubernetes 환경에 다양한 구성요소들을 배포할 수 있지만, python으로 템플릿 코드를 짜두면 개발자들도 편리하게 배포를 자동화할 수 있다. Jinja2와 kubernetes 패키지를 통해 사용자 요청에 따라 kubernetes 환경에 pod들을 띄우는 기능을 만들어보자.

    Domain entity와 ORM을 따로 구분해서 정의하는 방법 - Domain entity with imperative ORM

    Domain entity를 정의할 때 declarative 방식의 ORM을 사용하면 domain model에 DB 관련 정보가 노출되는 문제가 있다. Python sqlalchemy를 활용하여 imperative mapping style로 domain entity로부터 ORM을 분리해보자.

    도메인 entity와 ORM을 동시에 추구하면 안 되는 걸까? - Domain entity with declarative ORM

    ORM을 사용하는 환경에서 DDD를 따르는 코드를 작성하다보면, domain entity를 어떤식으로 정의해야 하는지 혼란스러울 때가 있다. Python sqlchemy를 통해 declarative mapping 방식으로 domain entity를 어떻게 구현하면 좋은지 알아보자.

    Font Size