[REAL Python – Flask] – “Flask HTTP API(2) – Flask-RESTful 로 생성, 조회 api 구축하기”
[REAL Python – Flask] – “Flask HTTP API(2) – Flask-RESTful 로 생성, 조회 api 구축하기”
환경 설정 및 프로젝트 생성
프로젝트 하나를 만들겠습니다. 가상 환경을 활성화하고, 아래의 내용을, requirements.txt 라는 파일로 저장해 주세요!
aniso8601==9.0.1 click==8.1.3 Flask==2.2.0 Flask-RESTful==0.3.9 itsdangerous==2.1.2 Jinja2==3.1.2 MarkupSafe==2.1.1 pytz==2022.1 six==1.16.0 Werkzeug==2.2.1
이후, pip install -r requirements.txt
를 터미널에 입력하여 필요한 라이브러리들을 설치해 줍니다.
조금 더 쉬운 api 구축을 도와주는 Flask-Restful
의 동작 원리
위와 같은 코드를 작성해보겠습니다. 아마도 지금까지 보아 왔던 코드의 형태와는 꽤 다를 겁니다.
4번 줄에서 플라스크 앱을 하나 작성하고, 5번 줄에서 Api 객체를 만들었죠? 이후 8번 줄에서 클래스 하나를 정의했는데, 이는 flask_restful의 Resource를 상속받아 만들어졌습니다. get이라는 메서드를 하나 정의했고, 이는 딕셔너리를 리턴하네요. 앱을 실행한 후, 브라우저에 아래의 주소를 입력해 보세요.
분명 get() 에서는 딕셔너리를 리턴했지만, jsonify()를 사용하지 않고도 위의 주소로 접근하면 플라스크는 우리에게 JSON 을 응답하는 것을 볼 수 있습니다.
어떻게 이런 일이 가능한 걸까요?
flask-restful
과 flask
의 소스를 좀 파헤쳐 보겠습니다.
클래스들의 상속 관계는 위와 같습니다. 맨 마지막의 Resource 클래스는 Flask-Restful 라이브러리에 정의되어 있고, 나머지 두 개의 클래스는 Flask에 정의되어 있습니다. 가장 모체 클래스로 보이는 View
는 flask.views
에 정의되어 있습니다. View 클래스는 아래와 같은 모습입니다. 크게 두 가지 메소드가 정의되어 있는 것이 보이죠?
처음으로 보이는 dispatch_request
는 아래와 같이 생겼습니다. 단순히 NotImplementdError
를 일으키게끔만 구현되어 있네요.
실제 보기 기능 동작. 서브클래스는 이것을 재정의하고 유효한 응답을 반환해야 합니다. URL 규칙의 모든 변수는 키워드 인수로 전달됩니다.
아직 무슨 이야기인지 좀 헷갈릴 수 있습니다. 예제를 살펴보기 전에, 또 보이는 as_view
메서드를 살펴보겠습니다. as_view
의 구현은 아래와 같습니다.
클래스를 경로에 등록할 수 있는 보기 함수로 변환합니다.
코드와 설명을 읽으니 더 헷갈립니다. 일단 이 두 메서드의 존재만 기억해 두고, 익숙한 형태의 코드를 살펴보겠습니다.
@app.route("/users/")
def user_list():
users = User.query.all()
return render_template("users.html", users=users)
지금까지 블로그 웹 애플리케이션 만들기 시리즈를 잘 따라왔다면, 위의 코드가 의미하는 것이 무엇인지를 쉽게 파악할 수 있을 것입니다. 모든 사용자 정보를 얻어온 다음, 템플릿에다 그것을 context 로서 전달해주고 있죠? 실제 users.html 에서는 {% for user in users %} 와 같은 템플릿 태그를 사용해서 유저 정보를 뿌려줄 수 있을 겁니다. 그리고 이는 user_list 라는 함수네요!
그런데, 우리가 어떤 모델(사용자, 게시물, 댓글 등) 에 대한 목록 페이지를 몇 개 더 만들어야 한다고 가정해 봅시다. 예컨대, “내가 가지고 있는 키보드의 목록” 을 템플릿에 뿌려주고 싶다면 어떻게 코드를 짜면 될까요?
@app.route("/keyboards/")
def keyboard_list():
keyboards = Keyboard.query.all()
return render_template("keyboards.html", keyboards=keyboards)
예측하기 아주 쉽습니다. 위와 같은 형태가 될 겁니다. 함수 를 정의해 준 것입니다. 또, “우리반 학생들의 목록” 페이지를 만들고 싶다면 어떻게 하면 될까요?
@app.route("/students/")
def student_list():
students = Student.query.all()
return render_template("students.html", students=students)
지금까지 작성했던, 비슷한 형태의 함수 를 작성할 수 있을 겁니다. 이쯤 되면 공통적인 무언가가 보이지 않나요? 이것들을, 클래스형 뷰로 바꿔 보겠습니다.
from flask.views import View
class UserList(View):
def dispatch_request(self):
users = User.query.all()
return render_template("users.html", objects=users)
app.add_url_rule("/users/", view_func=UserList.as_view("user_list"))
위에서 살펴보았던 View 라는 클래스를 상속받아서, dispatch_request 메서드를 재정의했습니다. 그리고 그것은 render_template() 을 리턴하도록 되어 있네요. 그 아래에서, 무언지는 잘 모르겠지만 add_url_rule() 을 사용하여, “/users/” 와 view_func를 지정해 주고 있습니다. 코드 내용으로 추측해보건대 “/users/” 는 url처럼 보이고, view_func 는 함수처럼 보입니다. 플라스크 공식 문서는 아래와 같은 설명을 해 주고 있습니다.
View.dispatch_request() 메소드는 view 함수와 동일합니다. View.as_view() 메서드를 호출하면 add_url_rule() 메서드로 앱에 등록할 수 있는 뷰 함수가 생성됩니다. as_view에 대한 첫 번째 인수는 url_for()를 사용하여 뷰를 참조하는 데 사용할 이름입니다.
위의 View 클래스에서의 dispatch_request() 메서드는 @app.route() 로 장식해 주었던 view 함수와 같은 기능을 수행하고, as_view 를 호출함으로서 뷰 함수 를 생성해 준다고 합니다. as_view() 의 첫 번째 인수는 위의 코드에서 “user_list” 로 정해져 있는데, 이는 url_for() 을 사용할 때에 함수명으로서 참조할 수 있는 이름입니다. 예컨대 url_for(“user_list”) 와 같이 사용할 수 있다는 것입니다.
좋아요! 위처럼 클래스를 사용해서 뷰 함수를 만들어낼 수 있다는 것을 알았습니다. 위의 코드 예시였던 함수로 뷰 만들기 1,2,3 에서의 문제점은 하나의 모델(키보드, 사용자, 자동차) 에 대한 목록 뷰를 만들 때마다 각각 함수를 정의해줘야 했다는 것입니다. 그래서 def user_list():, def keyboard_list():, def car_list():
와 같은 함수를 정의해주었습니다. 클래스형 뷰에서는, 아래와 같이 목록 뷰를 조금 더 유연하게 사용할 수 있습니다.
class ListView(View):
# 클래스를 생성할 때에, 모델과 템플릿 이름을 정해준다.
def __init__(self, model, template):
self.model = model
self.template = template
# 실제 뷰 함수가 하는 역할을 수행한다.
def dispatch_request(self):
items = self.model.query.all()
return render_template(self.template, items=items)
# 사용자 목록 뷰를 만들고 싶어!
app.add_url_rule(
"/users/",
view_func=ListView.as_view("user_list", User, "users.html"),
)
# 학생 목록 뷰를 만들고 싶어!
app.add_url_rule(
"/students/",
view_func=ListView.as_view("student_list", Student, "students.html"),
)
# 자동차 목록 뷰를 만들고 싶어!
app.add_url_rule(
"/cars/",
view_func=ListView.as_view("car_list", Car, "cars.html"),
)
위와 같이 클래스를 구성할 경우에는 모델이 몇 개가 있다 한들 함수를 각각 작성할 필요 없이 아래와 같이 등록해주면 됩니다. 이렇게 플라스크의 View
클래스에 대해서 알아보았습니다. 다음은, View
클래스를 상속받은 MethodView
클래스입니다.
MethodView
클래스는 아래와 같은 형태로 정의되어 있습니다.
위의 View
클래스의 dispatch_request
메서드를 재정의한 것이 보이나요? 플라스크의 공식 문서는 MethodView 에 대해서 아래와 같이 소개하고 있습니다.
해당 인스턴스 메서드에 요청 메서드를 전달합니다. 예를 들어 get 메소드를 구현하면 GET 요청을 처리하는 데 사용됩니다.
def get():
과 같은 메서드를 구현하면 get 요청을 처리할 수 있도록 한다고 하네요. 플라스크 공식 문서의 예제는 아래와 같이 구성되어 있습니다.
from flask.views import MethodView
class ItemAPI(MethodView):
init_every_request = False
def __init__(self, model):
self.model
self.validator = generate_validator(model)
def _get_item(self, id):
return self.model.query.get_or_404(id)
def get(self, id):
user = self._get_item(id)
return jsonify(item.to_json())
def patch(self, id):
item = self._get_item(id)
errors = self.validator.validate(item, request.json)
if errors:
return jsonify(errors), 400
item.update_from_json(request.json)
db.session.commit()
return jsonify(item.to_json())
def delete(self, id):
item = self._get_item(id)
db.session.delete(item)
db.session.commit()
return "", 204
class GroupAPI(MethodView):
init_every_request = False
def __init__(self, model):
self.model = model
self.validator = generate_validator(model, create=True)
def get(self):
items = self.model.query.all()
return jsonify([item.to_json() for item in items])
def post(self):
errors = self.validator.validate(request.json)
if errors:
return jsonify(errors), 400
db.session.add(self.model.from_json(request.json))
db.session.commit()
return jsonify(item.to_json())
def register_api(app, model, url):
app.add_url_rule(f"/{name}/<int:id>", view_func=ItemAPI(f"{name}-item", model))
app.add_url_rule(f"/{name}/", view_func=GroupAPI(f"{name}-group", model))
register_api(app, User, "users")
register_api(app, Story, "stories")
종국에는, 아래와 같은 기능을 하는 API가 구현되는 것입니다.
여기서 짚고 넘어가야 할 것, MethodView
를 상속받아 클래스를 만들고, get(), post(), put()
과 같은 메서드를 정의하면 우리의 뷰는 각각 GET, POST, PUT
과 같은 요청들을 처리할 수 있습니다.
그리고, 마지막으로 MethodView
를 상속받은 Flask-Restful
의 Resource
클래스를 보겠습니다. 이 클래스에서도 dispatch_request
메서드를 재정의한 것을 볼 수 있습니다.
위 함수의 로직은 https://velog.io/@city7310/flask-restful-A-to-Z-2.-flaskrestful.Resource-flaskrestful.Api#resource 에 잘 설명되어 있어 인용하고자 합니다.
위의 흐름을 이해한 후, 소스를 다시 살펴보면 어떻게 우리의 api 가 작동했는지를 어느 정도 감을 잡을 수 있을 것입니다. Resource
는 MethodView
를 상속받아 만들어졌으므로, get()
메서드를 구현함으로서 클라이언트가 보내는 GET 요청을 처리할 수 있게끔 하였고, flask-restful
은 get()
이 반환한 딕셔너리를 적절히 json
으로 응답하게끔 해 주도록 처리해줬습니다.
API 디자인해 보기
이제 우리가 작성하고자 하는 api 를 설계해 봅시다. 메모장이 되어도 좋고, 엑셀이 되어도 좋고, POSTMAN이 되어도 좋습니다.
위와 같이 Collections 에 우리가 새로 만들고자 하는 api 이름으로 폴더를 하나 만들어 주겠습니다. 자동차를 다루는 api를 만들 것이므로, 이름은 car-api로 하겠습니다.
모든 자동차의 목록 api, GET /cars
첫째로는 /cars 라는 엔드포인트를 하나 만들어주겠습니다. 이와 같은 GET 요청을 보내면, 서버는 우리에게 모든 자동차의 목록을 응답해주어야 합니다.
- GET /cars -> 모든 자동차의 목록 반환
특정 자동차에 대한 디테일 정보 api, GET /car/<name>
/car/<자동차 이름> 으로 GET 요청을 보냈을 때에, 우리는 그 이름에 맞는 자동차의 상세정보가 나오게끔 서버를 구성할 겁니다. 이러한 경우, 자동차의 이름은 자동차 목록들에서 유일해야 합니다. “모닝” 이라는 자동차가 두 대 있다면, /car/모닝 으로 요청을 보낸다면 차 한 대가 아닌 두 대의 정보를 보여줄 테니까요.
- GET /car/<name> -> 이름으로 고유하게 식별되는 특정 자동차 한 대의 상세정보를 반환, 자동차는 같은 이름을 가질 수 없음
자동차 하나를 생성하는 api, POST /car/<name>
같은 엔드포인트 /car/<name> 으로, json 을 보내주고 새로운 자동차를 생성하게끔 할 수 있을 겁니다. header 부분에는 “이 엔드포인트에서 서버가 받을 걸로 예상하는 것은 json이야!” 를 알려주고, body 부분에는 아래와 같이 “자동차의 가격은 얼마야!” 를 JSON 포맷으로 보낼 수 있을 겁니다.
- POST /car/<name> -> <name> 이라는 이름을 가지는 자동차 하나를 생성, 같은 이름을 가지고 있는 자동차가 있다면 생성은 실패해야 함
자동차 하나를 삭제하는 api, DELETE /car/<name>
DELETE 요청을 사용하여 이름으로 식별되는 자동차 하나를 삭제할 수도 있겠죠?
- DELETE /car/<name> -> <name> 이라는 이름을 가지고 있는 자동차를 삭제
이미 존재하는 자동차 정보를 수정하는 api, PUT /car/<name>
수정을 위한 HTTP Method 는 PUT 입니다.
이곳에서 PUT 은 두 가지 중 하나의 역할을 수행할 겁니다. 첫째 – 새로운 자동차를 생성하거나, 둘째 – 이미 존재하는 자동차를 수정할 겁니다. 아래와 같이 설계될 수 있겠죠?
POST 와는 다르게, PUT 은 같은 이름을 가지고 있는 자동차가 있다고 하더라도 실패하지 않을 겁니다.
- PUT /car/<name> -> <name> 이라는 이름을 가지고 있는 자동차를 생성하거나, 이미 그 이름을 가지고 있는 자동차가 존재한다면 정보를 수정
그러면, 최종적으로 아래와 같을 겁니다.
우리는 두 가지 리소스를 다루고 있습니다. 하나는 자동차 목록인 cars
, 또 하나는 자동차 하나인 car
입니다. 이 둘은 다른 리소스입니다.
Car
생성, 조회 API 구현해 보기
리소스를 하나 만들고 – 그 리소스에 대한 http 메서드별 메소드들을 구현하면 될 겁니다. 큰 틀은, 아래와 같습니다.
보통 자동차들과 같은 데이터는 데이터베이스에 저장될 겁니다.. 만, 이번에는 간단히 하기 위해서 파이썬 리스트를 통해 저장해보겠습니다.
일단, name에 맞는 자동차의 상세정보를 알 수 있게 해 주는 get
부터 구현해 봅시다. /car/benz
처럼 요청을 보냈다면, 우리의 서버는 'benz'
라는 이름을 가진 자동차가 있는지 확인하고 있다면 그것에 대한 상세정보를 돌려줘야 합니다.
큰 어려움 없이, 위와 같이 구현할 수 있을 겁니다. “리스트” 를 순회하며 맞는 이름이 있으면 그것을 리턴하도록 되어 있네요. 만약 우리가 데이터베이스를 사용했다면, “데이터베이스 안에 같은 이름을 가지고 있는 자동차가 있다면, 그것에 대한 정보를 리턴한다” 의 논리가 될 겁니다.
다음은 새로운 자동차를 생성하기 위해서 post
메서드를 구현해 보겠습니다. 우리는 리스트에 자동차들을 저장하기로 했으므로 파이썬의 append 메서드를 사용할 수 있을 겁니다. URL에 있는 이름으로, 그리고 가격은 랜덤으로 정해 보도록 하겠습니다. random 모듈을 사용하기 위해서, import random 코드도 추가해 주세요.
우리는 이렇게 car/<name> 에 관한 get, post 요청을 받을 수 있는 엔드포인트들을 구현했습니다. 실제로 포스트맨과 같은 도구를 사용해서 우리들의 api 가 동작하는지 테스트해 보겠습니다. 맨 아래에 아래의 소스를 넣는 것을 잊지 마세요!
위와 같이 URL에 자동차 이름을 넣어서 POST 요청을 보내 보세요. 서버는 요청을 잘 처리하고, 새로 생성된 자동차에 대한 정보를 반환해 줄 겁니다.
그러면, 하나의 새로운 자동차를 생성했으니 이번에는 같은 이름으로 조회를 해 볼까요? /cars/mecedes-benz 로 get 요청을 보내 보겠습니다.
원하던 대로 알맞는 자동차 정보를 응답해준 것을 확인할 수 있네요!
그런데, 만약 없는 자동차 이름인 “damas” 로 get 요청을 보내면 어떻게 될까요?
요청을 보내 보면, 서버는 아래와 같이 null을 리턴합니다.
이는 괜찮은 응답 방식이 아닙니다. 우측의 상태 코드를 살펴보면, 200이라고 알려주고 있죠? api 서버는 적절한 상태 코드 응답을 해 줘야 합니다. 리소스가 없을 때에 서버가 응답해야 하는 상태 코드는 404입니다.
또, POST 요청을 보냈을 때에 서버는 우리에게 201 상태 코드를 돌려줘야 합니다.
그렇다면 적절한 상태 코드를 반환하게끔 어떻게 처리할 수 있을까요? 아래와 같이 작성해 보겠습니다.
메서드를 구현할 때에 위와 같이 상태 코드를 같이 리턴해 주면 됩니다. 실제로 없는 자동차 이름에 get 요청을 보낸다면, 상태 코드로서 404를 돌려주는 것을 확인할 수 있죠!
마찬가지로, POST 요청이 성공했을 때에는 상태 코드로서 201 을 응답해야 합니다.
import random
from flask import Flask
from flask_restful import Resource, Api
app = Flask(__name__)
api = Api(app)
# 보통 "자동차" 와 같은 것들은 "데이터베이스" 에 저장되지만,
# 파이썬 리스트로 저장하기로 한다.
cars = []
class Car(Resource):
'''
Car 이라는 리소스를 다룰 것인데,
get, post, delete, put 메서드들을 정의할 것이다.
'''
def get(self, name):
for car in cars:
if car['name'] == name:
return car
return {'car' : None}, 404
def post(self, name):
car = {'name': name, 'price' : random.randrange(1000000, 1000000000)}
cars.append(car)
return car, 201
def delete(self, name):
pass
def put(self, name):
pass
api.add_resource(Car, '/car/<string:name>')
if __name__ == "__main__":
app.run(debug=True)
Car list API 구현해 보기
자동차 목록 리소스에 대한 메서드는 get() 만 구현하면 됩니다. Car 리소스와 Cars 리소스는 분리되어 있는 것이므로, 별도의 리소스를 더 작성해줍니다. 아래와 같은 형태가 될 겁니다.
JSON Payload 를 통해서 자동차 생성하기
현재, 우리가 만들어낸 “자동차 생성 api” 는 아래와 같은 과정을 거칩니다.
우리가 하고자 하는 것은 “Postman 과 같이, 누군가가 JSON에 ‘가격은 얼마로 해서 자동차를 하나 추가해 줘!’ 라고 요청을 보낸다면 그것을 처리하는 것” 입니다. 현재 위의 로직을 살펴보면 자동차의 가격은 랜덤으로 정해집니다. 이 부분을 고쳐 보겠습니다.
당연히 생각나는 것은 누군가 우리의 api 에 요청을 보내고, Body 부분에 json 을 담아 요청을 보낸다면 우리의 서버는 그것을 해석할 수 있어야 한다는 것입니다.
플라스크는 get_json() 을 통해서 그러한 기능을 제공합니다. get_json() 은 만약 요청을 보낼 때에 헤더가 제대로 세팅되지 않았거나, json이 아닌 다른 코드를 작성해서 요청을 보낸다면 에러를 발생시킬 겁니다.
그러면, 위와 같이 data를 콘솔에 출력해 봅시다.
POST 요청을 보낼 것이므로 메서드를 정해 준 다음, 위와 같이 header 가 application/json 으로 세팅되어있는지 확인하고,
위와 같이 Body 부분에는 가격을 담아 보낼 겁니다. send 버튼을 누르면, 아래와 같이 콘솔에 우리가 보낸 데이터가 찍히는 걸 확인할 수 있을 겁니다. 그리고, 그것은 json이 아닌 파이썬 딕셔너리입니다.
포스트맨에서 보낸 json 데이터가 파이썬의 딕셔너리로, data 라는 변수에 담겨 있네요. 우리가 할 일은, 자동차 리스트에 자동차를 추가하는 겁니다. 리스트이므로, append() 를 쓸 수 있겠죠?
new_car 이라는 변수에 새로운 딕셔너리를 담고, cars 라는 리스트에 담아 줍니다. 그리고, 우리의 서버는 만약 새로운 자동차를 생성하는 데 성공하면 그 생성된 자동차의 정보와, 상태 코드를 201로 응답해줄 겁니다.
리팩토링
먼저, 우리가 구현한 get() 을 약간 변경해 보겠습니다.
위의 코드를, 파이썬 내장 함수인 filter() 을 통해 개선해 보겠습니다.
next()
는 빌트인 함수로서, 아래의 설명을 보면 “이터레이터의 다음 item을 반환합니다
” 라고 되어 있습니다. 기본값을 설정할 수 있습니다. 개선된 코드에서의 기본값은 None 이네요.filter()
는함수, 이터러블
를 받아filter object
를 만들어냅니다.
현재 우리의 api 는 자동차의 이름이 모두 달라야 합니다. 만약 새로운 자동차를 생성할 때에 같은 자동차의 이름이 존재한다면, 그것은 거부되어야 합니다. 우리가 구현한 get()
메서드는 이름으로 자동차의 상세정보를 리턴하는데, 만약 이름이 중복되는 자동차 두 개가 있다면 두 개의 자동차 정보를 리턴할 테니까요. 이 부분을 고쳐 보겠습니다.
next() 는 이터레이터의 다음 것을 반환합니다. 예를 들면, 아래와 같습니다.
아래와 같이 코드를 개선할 수 있을 겁니다. 만약 이미 존재하는 자동차의 이름으로 새 자동차를 생성해 달라는 요청이 들어오면, 에러 메시지와 함께 상태 코드로 400을 리턴하도록 하였습니다.
상태 코드 400은 서버가 클라이언트 오류를 감지해 요청을 처리할 수 없거나, 하지 않는다는 것을 의미합니다. 클라이언트에서 잘못된 요청을 보냈으므로, 우리는 400을 상태 코드로서 리턴해 주었습니다.
현재까지의 모든 app.py
import pprint
import random
from flask import Flask, request
from flask_restful import Resource, Api
app = Flask(__name__)
api = Api(app)
# 보통 "자동차" 와 같은 것들은 "데이터베이스" 에 저장되지만,
# 파이썬 리스트로 저장하기로 한다.
cars = []
class Car(Resource):
'''
Car 이라는 리소스를 다룰 것인데,
get, post, delete, put 메서드들을 정의할 것이다.
'''
def get(self, name):
car = next(filter(lambda x: x['name'] == name, cars), None)
return {'car' : car}, 200 if car else 404
def post(self, name):
# 만약, 이미 존재하는 자동차의 이름으로 새 자동차를 생성해 달라는 요청이 들어오면,
# error 메시지와 함께 400 상태 코드를 리턴
if next(filter(lambda x: x['name'] == name, cars), None):
return {'error' : f'{name} item already exists.'}, 400
data = request.get_json()
new_car = {'name' : name, 'price' : data['price']}
cars.append(new_car)
return new_car, 201
def delete(self, name):
pass
def put(self, name):
pass
class CarList(Resource):
def get(self):
return {'cars' : cars}
api.add_resource(Car, '/car/<string:name>')
api.add_resource(CarList, '/cars')
if __name__ == "__main__":
app.run(debug=True)