Docker + Python 운영환경 정복하기 with Flask 3
안녕하세요 블루프린트랩 개발자 구상모입니다. 벌써 3번째 포스팅입니다. 시간이 참 빠르게 지나가는 것 같습니다.
Docker를 활용해서 Python으로 운영환경을 만드는 중인데 이번 포스팅 부터는 이전 포스팅을 참고하여 진행해주시면 됩니다.
Project Name
- flask-api-demo
- github: https://github.com/bplab/flask_api_demo
part 1 : Docker + Python 운영환경 정복하기 with Flask 1
part 2 : Docker + Python 운영환경 정복하기 with Flask 2
Chapter 3 - Testing Codes for Authentication, Authorization
이번 Chapter 3 에서는 권한 및 인증을 위한 Test Code를 작성할 것 입니다. 앞으로의 이해를 위하여 간단하게 먼저 인증에 대해서 설명 하도록 하겠습니다.
인증 이란 Resource를 사용하는 User의 신원을 확인하는 일 입니다. 웹 서비스에 로그인을 통해 인증을 받으면 권한 내에서 자유롭게 웹 서비스 Resource를 사용할 수 있게 됩니다. 웹 서비스에서 User의 인증 방식은 크게 두가지로 나뉩니다.
-
서버 기반 인증: 서버 기반 인증은 서버에 인증 기록을 저장하는 방식입니다. 서버에 저장된 것을 세션 이라 부르고 세션이 많아진다면 서버가 과부하가 걸릴 수 있습니다. 즉 다시말해 로그인 하는 User수가 많아 질 수록 서버 성능에 영향을 끼치는 것 입니다.
-
토큰 기반 인증: 토큰 기반 인증은 서버가 인증 기록을 세션에 담아두지 않습니다. 서버측에서 User 정보를 검증 후 User에게 토큰을 발급하고 이 토큰을 User가 사용하여 로그인 할때마다 해당 토큰을 헤더에 담아 request 합니다.
인증에 대해 간단한 설명은 이정도로 충분할 것 같습니다. 저희는 여기서 토큰 기반 인증을 사용한 Test Code를 작성 할 것 입니다.
이제 본격적으로 Test Code를 작성하는 과정을 보도록 하겠습니다.
1. Set MongoDB container
Mongo DB 는 SQL을 사용하지 않는 NoSQL의 대표적인 Database 입니다.
Mongo DB 에 대한 자세한 사항은 MongoDB 에서 확인하실 수 있습니다.
Docker와 Docker-compose에 대한 간단한 설명은 이전 포스팅 에서 확인하실 수 있습니다.
Mongo DB 를 Docker container로 구축하기 위해 먼저 docker-compose.yml 을 보도록 하겠습니다.
docker-compose.yml 예제
version: '3'
services:
api:
image: python:latest
volumes:
- ${proj_path}:/root/flask_api_demo
working_dir: /root/flask_api_demo
depends_on:
- db
db:
image: mongo:latest
volumes:
- mongo_configdb:/data/configdb
- mongo_db:/data/db
ports:
- "27017:27017"
volumes:
mongo_configdb:
mongo_db:
-
예제에서 service 가 db이고 Docker image로 mongo:latest 인 것을 확인할 수 있습니다. 새롭게 추가된 내용은 ports 옵션입니다. 기본적으로 docker container가 생성되면 Host 네트워크에서 Container로의 통신이 불가능합니다.
-
docker conatiner가 Host와 통신이 가능하려면 port를 설정해줘야 하는데 이때 docker-compose의 ports 옵션으로 container의 외부 노출 port를 설정할 수 있습니다.
-
db container의 port가 27017:27107 로 설정되어 있는 것을 확인 할 수 있습니다. 이는 Host에서 27107 port로 요청이 들어오면 db container의 27017 port로 forwarding 한다는 뜻 입니다. port 형식은
<Host port>:<container port>
형식으로 쓰입니다.
docker-compose.yml을 실행시켜보겠습니다. 이전에 작성한 run-docker-compose.sh 를 실행시킵니다.
run-docker-compose sciprt 실행 화면
실행화면을 확인하면 Creating flaskapidemo_db_1 로 db container도 함께 생성되는 것을 확인할 수 있습니다. script의 명령어인 docker-compose run --rm -p 5000:5000 api bash
로 api 만 실행시켜도 api에서 depends_on으로 인하여 정의해놓은 db container도 같이 생성됩니다.
2. Configure For Testing
이제 DB container 설정이 끝났습니다. 저희는 TDD 를 활용하기로 하였습니다. TDD란 Test Driven Development의 약자로써 말그대로 테스트 주도 개발이라는 뜻입니다.
TDD에 대해 간략히 설명해보도록 하곘습니다.
- 요구사항이 포함된 Test Case를 만듭니다.
- Test Case가 작성되었으면 테스트에 통과 하는 소스코드를 작성합니다.
- 테스트에 통과 하는 코드를 반복적으로 만들면서 제대로 동작하는지 확인하며 리팩토링을 진행합니다.
python Flask에서 Test Case를 작성하는데 필요한 것들을 하나씩 차례대로 진행하겠습니다.
1. Python Package 설치
Flask
Flask_restful
Flask_PyMongo
pytest
- requirements.txt에 필요한 package를 추가합니다.
- Flask에서 Mongo DB에 접근할 수 있는 Flask_PyMongo 와 Python test case를 실행시킬 수 있는 pytest package를 설치합니다.
지난 포스팅에서 작성해놓았던 install-python-dependencies.sh
를 사용해서 설치하도록 합니다.
2. config.py 예제
class Config(object):
DEBUG = False
TESTING = False
MONGO_URI = 'mongodb://db:27017/flask_api_demo'
class TestingConfig(Config):
TESTING = True
MONGO_URI = 'mongodb://db:27017/flask_api_demo_test'
class DevelopmentConfig(Config):
DEBUG = True
class ProductionConfig(Config):
pass
- config.py는 Flask를 실행시킬때 설정할 config들을 정의 합니다.
- MONGO_URI는 mongo DB 서버의 주소를 나타낸다.
- 상위에 정의된 class Config를 모든 class 들이 상속 받는 것을 확인 할 수 있습니다.
- TestingConfig : Test Code를 실행시키기 위해선 TestingConfig를 활용하여 사용할 예정입니다. 밑에서 천천히 설명하겠습니다.
여기까지 config.py를 구성하였고 실제로 python code에서 어떻게 쓰이는지 알아보겠습니다.
3. init.py 예제
from flask import Flask
from flask_restful import Api
import config
def create_app():
app = Flask(__name__)
if app.config['ENV'] == 'development':
app.config.from_object(config.DevelopmentConfig)
elif app.config['ENV'] == 'testing':
app.config.from_object(config.TestingConfig)
elif app.config['ENV'] == 'production':
app.config.from_object(config.ProductionConfig)
else:
raise ValueError('Check FLASK_ENV')
# ref. https://github.com/flask-restful/flask-restful/issues/280
handle_exception = app.handle_exception
handle_user_exception = app.handle_user_exception
from .resources.foo import (
foo_bp,
Hello,
)
api_foo = Api(foo_bp)
api_foo.add_resource(Hello, '/')
app.register_blueprint(foo_bp)
# ref. https://github.com/flask-restful/flask-restful/issues/280
app.handle_exception = handle_exception
app.handle_user_exception = handle_user_exception
return app
이전 소스코드와 비교해 보았을때 크게 바뀐점이 2가지가 있습니다.
- 첫번째로 Import Config가 추가되습니다. config.py를 python코드에서 import하여 flask instance에 어떠한 config가 적용이 될지 정하는 부분이 생겼습니다.
- 두번째로 resource를 import 하는 부분과 rest api가 밑으로 옮겨진 것을 확인할 수 있습니다. 그 이유는 config.py가 생기면서 앞으로 DB에 접근하게 될 때 config보다 DB로 접근 하는 api가 먼저 import 되면 에러가 날 수 있기 때문입니다.
3. Write Testing Code
Test Code에서 토큰 기반 인증으로 인증을 진행하며 웹 표준인 JWT를 사용하여 test case를 작성해보도록 하겠습니다.
저희는 pytest를 사용하였으며 본격적으로 python test case를 작성해보도록 하겠습니다.
pytest 에 관한 자세한 정보는 여기 에서 확인하실 수 있습니다.
1.conftest.py 예제
import pytest
import json
from flask_pymongo import PyMongo
from flask_api_demo import create_app
app = create_app()
if app.config['ENV'] != 'testing':
raise ValueError('Check FLASK_ENV')
@pytest.fixture(scope='session')
def reset_testing_db():
mongo = PyMongo(app)
cx = mongo.cx
yield reset_testing_db
cx.drop_database('flask_api_demo_test')
@pytest.fixture(scope='session')
def tester(reset_testing_db):
tester = app.test_client()
return tester
@pytest.fixture(scope='session')
def jwt(tester):
resp = tester.post(
'/users/registration',
data=json.dumps({'username': 'test', 'password': 'test'}),
content_type='application/json',
)
resp_data = resp.get_json()
result = {
'access_token': resp_data['access_token'],
'refresh_token': resp_data['refresh_token'],
}
return result
- yield pytest는 fixture가 스코프를 벗어날 때 특정 finalization 코드를 실행하는 것을 지원합니다. return 대신 yield 상태를 사용해서, yield 상태 후에 모든 코드들은 teardown code로서 사용됩니다.
- test function은
@pytest.fixture
라는 fixture 데코레이터로 정의 되어있으며 fixture 객체를 받을 수 있습니다. fixture는 testing에 필요한 조건들을 미리 준비해놓는 코드로 볼 수 있습니다. - reset_testing_db : mongoDB의 test 용도 database를 생성하며 yeild 예약어를 사용하는데 yeild를 사용하면 reset_testing_db가 끝나고 test 용도 database를 삭제합니다.
- tester : flask test client instance 입니다. 여기서 tester는 curl이나 postman 같은 역할을 합니다.
- jwt : JWT token을 앞으로 우리가 구현해야할 서버로 부터 발급받는 함수입니다.
- conftest.py는 전체적으로 Test를 위해서 준비하는 과정이라고 볼 수 있습니다. 정의된 tester를 통해 token을 발급받고 tester가 flask client instance를 생성할 때 Test 용도 Database를 reset_testing_db로 만드는 것을 확인 할 수 있습니다. 코드의 흐름을 살펴보면 쉽게 파악 하실 수 있을 것 입니다.
2. test_foo.py 예제
def test_hello_ok(tester):
resp = tester.get(
'/tests/hello',
content_type='application/json',
)
assert {'Hello': 'World!'} == resp.get_json()
assert 200 == resp.status_code
def test_secret_ok(tester, jwt):
resp = tester.get(
'/tests/secret',
headers={'Authorization': 'Bearer {}'.format(jwt['access_token'])},
content_type='application/json',
)
assert {'Hello': 'Secret!'} == resp.get_json()
assert 200 == resp.status_code
def test_secret_without_header(tester):
resp = tester.get(
'/tests/secret',
headers=None,
content_type='application/json',
)
assert {'msg': 'Missing Authorization Header'} == resp.get_json()
assert 401 == resp.status_code
def test_secret_with_refresh_token(tester, jwt):
resp = tester.get(
'/tests/secret',
headers={'Authorization': 'Bearer {}'.format(jwt['refresh_token'])},
content_type='application/json',
)
assert {'msg': 'Only access tokens are allowed'} == resp.get_json()
assert 422 == resp.status_code
def test_secret_with_bad_access_token(tester, jwt):
resp = tester.get(
'/tests/secret',
headers={'Authorization': 'Bearer {}extra-string'.format(jwt['access_token'])},
content_type='application/json',
)
assert {'msg': 'Signature verification failed'} == resp.get_json()
assert 422 == resp.status_code
def test_secret_with_bad_jwt(tester):
resp = tester.get(
'/tests/secret',
headers={'Authorization': 'Bearer {}'.format('this-is-not-json-web-token')},
content_type='application/json',
)
assert {'msg': 'Not enough segments'} == resp.get_json()
assert 422 == resp.status_code
def test_secret_with_bad_header(tester):
resp = tester.get(
'/tests/secret',
headers={'Authorization': '{}'.format('this-is-plain-text')},
content_type='application/json',
)
assert {'msg': "Bad Authorization header. Expected value 'Bearer <JWT>'"} == resp.get_json()
assert 422 == resp.status_code
- pytest는 test_ prefix가 붙은 function과 file을 test할 것으로 간주합니다. 그러므로 file 이름과 function에 test_ 가 붙은 것을 확인 할 수 있습니다.
- 여기서 test_가 붙은 function은 하나의 Test Case로 보면 됩니다.
- User는 Access Token과 Refresh Token 을 발급 받습니다. Access Token은 로그인할때 사용되며 Refresh Token은 Access Token의 유효 시간을 갱신 해주는 Token 입니다.
- User는 token을 header에 담아 request 보내는데 header의 형식은
{'Authorization' : 'Bearer <Access Token> }
와 같습니다. - 전체적인 Test Case는 Token이 정상적일때와 비정상적일 때의 Case를 고려하였습니다.
자 이제 거의 다 왔습니다… 조금만 더하면 됩니다!!
run-pytest.sh 예제
#!/bin/sh
cd $(cd "$(dirname "$0")" && pwd)
. venv/bin/activate
export FLASK_ENV=testing
pytest -s -v --tb=short
저희는 모든 명령어를 script 화 하였습니다. pytest도 마찬가지 script를 통해서 실행시키도록 하겠습니다.
- export FLASK_ENV=testing : Flask ENV를 testing으로 설정함으로써 config.py의 TestingConfig의 config를 가지게 됩니다.
- pytest -s -v --tb=short : pytest의 명령어 입니다. -s 옵션은 가장 수행시간이 길었던 10개의 test를 출력하는 명령어 입니다. -v는 pytest의 information을 좀 더 자세히 출력합니다. 마지막으로 --tb=short 짧은 형식의 traceback을 나타냅니다.
중요 Point Script에서 확인 하실 수 있듯이 FLASK_ENV를 testing으로 설정한 것을 볼 수 있습니다. 설정 후에 pytest로 test를 실행시키면 conftest.py에서 정의된 app = create_app()에서 if문으로 인해 config.py의 TestConfig로 설정된 Flask instance가 app 변수에 정의 됩니다. TestingConfig의 TESTING = True 로 인하여 Flask의 Testing Mode가 활성화 되며 로 인해 Testing Mode가 활성화된 Flask instance가 생성 되는 것 입니다.
Pytest 구동 화면
test case에 통과하지 못하였지만 이제 앞으로 저 빨간색 들이 초록색으로 변하는 날까지 꾸준히 포스팅 하도록 하겠습니다. 모두 수고 하셨습니다!!!
댓글남기기