後端程式我們使用 flask + flask_restful 來完成我們的 REST API, 為什麼不使用 nodejs 來完成呢?nodejs 語言對我來說是最不適合來寫程式,主要邏輯中一大堆不必要的迴圈一大堆的非線性處理,用你最上手的程式語言就好,二來 python 還有很多優秀的 libraries,可以處理很多事情。
測試時使用 sqlite 資料庫,也可以替換成其他 SQL 資料庫。
pandas 幾乎在其他語言中沒有替代品,已經用 python 開發出各種工具,開發速度也比之前使用 ruby 還要方便直覺,財報分析、股票績效計算、Schweb 轉 MyStocksApp、繁轉轉換等都是透過 python 完成,目前陸續整合成 API ,陸續會開放出來。常用的 library 如下表:
數據整理或分析統計原始流程
修改後的流程:左半邊是原始程式產生數據A,右半邊是修改後程式產生數據B
flask 網站開發 framework
flask_restful 快速開發 API
flask_sqlalchemy 簡單快速上手使用 sqlalchemy library 支援各種主流的 SQL 資料庫
前端在上一篇已經完成,這邊使用 python 來完成後端系統,依照功能與用途分別有五個步驟,分別放在不同的目錄之下,可以看下圖目錄架構。
# 後端目錄架構
src #
project #
app # 主程式
handlers # api
models # model 設定
settings # 系統設定
tests # 測試
使用 sql 的資料庫最麻煩的一點就是只要表格有變動,資料庫也要變動,不然完全不能使用。
在 models 目錄下新增 user.py
檔案,這裡很簡單就只是定義 User SQL table 欄位。
# z5525/models/user.py
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()
class User(db.Model):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(50), unique=False, nullable=False)
email = db.Column(db.String(100), unique=True, nullable=False)
password = db.Column(db.String(120), nullable=False)
is_admin = db.Column(db.Boolean, default=False) # 管理員權限
def __repr__(self):
return f"<User(username='{self.username}', email='{self.email}')>"
def to_dict(self):
d = {
"id": self.id,
"username": self.username,
"email": self.email,
"is_admin": self.is_admin,
}
return d
一樣是在 models 目錄新增 __init__.py
將 db 與 User 匯入到這個檔案之中,之後要引用 from z5525.models import db, User
就可以使用
# z5525/models/__init__.py
from z5525.models.user import db, User
1.寫 UserResource 用來讀取、新增、更新、刪除資料庫,現在只先完成 get user 和 users,最後面再補上新增、更增、刪除等。
# z5525/handlers/users.py
from flask import Flask, request, jsonify
from flask_restful import Api, Resource, abort
from werkzeug.security import generate_password_hash, check_password_hash
from z5525.models.user import db, User
import sqlalchemy.exc as sa_exc
#
class UserResource(Resource):
# READ
def get(self, user_id=None):
if user_id:
try:
user = db.session.execute(db.select(User).filter_by(id=user_id)).scalar_one()
return {
"status": "ok",
"total": 1,
"count": 1,
'user': user.to_dict()
}, 200
except sa_exc.NoResultFound:
return {
"error": "Not Found",
"message": 'User not found'
}, 404
else:
total = db.session.query(User).count() # 總數量
offset = request.args.get('offset', default=0, type=int)
limit = request.args.get('limit', default=10, type=int)
users = db.session.execute(db.select(User).offset(offset).limit(limit)).scalars().all()
return {
"status": "ok",
"total": total,
"count": len(users),
"offset": offset,
"limit": limit,
'items': [user.to_dict() for user in users]
}, 200
2.定義 API
# z5525/handlers/__init__.py
from flask_restful import Api, Resource
from milch.handlers.users import UserResource
APP_PREFIX = "/api"
api = Api()
api.add_resource(UserResource, f'{APP_PREFIX}/users', f'{APP_PREFIX}/users/<int:user_id>')
命名為 z5525/app/main.py
# z5525/app/main.py
from flask import Flask, request, jsonify
from z5525.models import db, User
from z5525.handlers import api
def create_app():
app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.json.compact = False
db.init_app(app)
api.init_app(app)
return app
if __name__ == '__main__':
app = create_app()
with app.app_context():
db.create_all()
app.run(port=5525)
最後補上 __init__.py
裡面只加入一行
# z5525/app/__init__.py
from .main import create_app
測試 python -m z5525.app.main
* Serving Flask app 'main'
* Debug mode: off
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
* Running on http://127.0.0.1:5525
看到以上的訊息表示已經起動,可以開始測試。打開瀏覽器 http://127.0.0.1:5525/api/users
或用 curl http://127.0.0.1:5525/api/users
指令去測試 User API 。
curl http://127.0.0.1:5525/api/users
{"status": "ok", "total": 0, "count": 0, "offset": 0, "limit": 10, "items": []}
在 目錄新增 user 測試,檔名命名為 test_user.py
1.測試 client
以下兩種都可以正常使用的 client,使用 create_app 的好處是,測試與正式上線的設定是一樣的,當有任何設定變更只需要在 settings 中變更,例如 、 目前範例都是寫死的,可以將這些設定統一到 settings 。
# 使用 create_app
@pytest.fixture
def client():
""" 建立 Flask 測試客戶端 """
app = create_app() # 在 z5525/app/main.py
with app.app_context():
db.create_all()
yield app.test_client()
# 使用全新的測試環境
@pytest.fixture
def client():
""" 建立 Flask 測試客戶端 """
app = Flask(__name__)
app.config['TESTING'] = True
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:' # 使用記憶體內的 SQLite
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db.init_app(app)
api = Api(app)
api.add_resource(UserResource, '/users', '/users/<int:user_id>')
with app.app_context():
db.create_all()
yield app.test_client()
2.測試數據
和 在參數中加入它後,每次的測試都是乾淨的,只要有加入會影響到測試的資料,反過來沒有加入的,測試數據庫是不會有的。
@pytest.fixture
def sample_user():
""" 插入一筆測試 """
user = User(
username="Test user",
email="[email protected]",
password="pass",
)
db.session.add(user)
db.session.commit()
return user
3.測試 get users
參數中加入了 和 ,因為已經加入 sample_user ,在這次的測試中數據庫裡面已經有一筆數據,這筆數據就是 sample_user 所產生的。
def test_get_users(client, sample_user):
""" 測試取得 users API,並檢查 JSON 格式 """
response = client.get(f'{APP_PREFIX}/users')
assert response.status_code == 200
assert "total" in response.json
assert "count" in response.json
assert "offset" in response.json
assert "limit" in response.json
assert "items" in response.json
assert len(response.json["items"]) == 1
將 pytest 測試的三種合起來就是一個測試檔案,將它寫在同一個檔案裡面,就會如下程式碼
# tests/test_user.py
import pytest
import os
from flask import Flask
from z5525.app.main import create_app
from z5525.handlers import api, UserResource
from z5525.models import db, User
APP_PREFIX = "/api"
@pytest.fixture
def client():
""" 建立 Flask 測試客戶端 """
app = create_app() # 在 z5525/app/main.py
with app.app_context():
db.create_all()
yield app.test_client()
@pytest.fixture
def sample_user():
""" 插入一筆測試產品 """
user = User(
username="Test user",
email="[email protected]",
password="pass",
)
db.session.add(user)
db.session.commit()
return user
def test_get_users(client, sample_user):
""" 測試取得 users API,並檢查 JSON 格式 """
response = client.get(f'{APP_PREFIX}/users')
assert response.status_code == 200
assert "total" in response.json
assert "count" in response.json
assert "offset" in response.json
assert "limit" in response.json
assert "items" in response.json
assert len(response.json["items"]) == 1
def test_get_users_limit(client, sample_user):
""" 測試取得 users API,並檢查 JSON 格式 """
response = client.get(f'{APP_PREFIX}/users?offset=0&limit=1')
assert response.status_code == 200
assert "total" in response.json
assert "count" in response.json
assert "offset" in response.json
assert "limit" in response.json
assert "items" in response.json
assert len(response.json["items"]) == 1
def test_get_user(client, sample_user):
""" 測試取得 user API """
response = client.get(f'{APP_PREFIX}/users/{sample_user.id}')
assert response.status_code == 200
assert "total" in response.json
assert "count" in response.json
assert "offset" in response.json
assert "limit" in response.json
assert "items" in response.json
assert response.json["total"] == 1
assert response.json["count"] == 1
assert response.json["user"]["username"] == "Test user"
一定要補上設定檔,不然執行 會找不到你寫的 module
# pyproject.toml
[tool.pytest.ini_options]
pythonpath = ["./"]
開始測試
pytest tests/test_user.py
或是 pytest
# z5525/models/user.py
class UserResource(Resource):
# CREATE
def post(self):
try:
data = request.get_json(silent=True)
except Exception as e:
return {
"error": "Bad Request",
"message": "Invalid JSON format in request body",
}, 400
username = data.get('username')
email = data.get('email')
password = data.get('password')
if not password or not email:
return {
"error": "Bad Request",
"message": 'password and email are required'}, 400
try:
user = db.session.execute(db.select(User).filter_by(email=email)).scalar_one()
return {
"error": "Bad Request",
"message": 'User already exists'}, 400
except sa_exc.NoResultFound:
""" Not Found: Create it """
hashed_password = generate_password_hash(password)
new_user = User(username=username, email=email, password=hashed_password)
db.session.add(new_user)
db.session.commit()
return {
"status": "ok",
'message': 'User added successfully'}, 201
# UPDATE
def put(self, user_id):
data = request.get_json(silent=True)
username = data.get('username')
email = data.get('email')
if not username or not email:
return {
"error": "Bad Request",
"message": 'Username and email are required'}, 400
try:
user = db.session.execute(db.select(User).filter_by(id=user_id)).scalar_one()
user.username = username
user.email = email
db.session.commit()
return {
"status": "ok",
"message": 'User updated successfully'}, 200
except sa_exc.NoResultFound:
return {
"error": "Not Found",
"message": 'User not found'}, 404
# DELETE
def delete(self, user_id):
try:
user = db.session.execute(db.select(User).filter_by(id=user_id)).scalar_one()
db.session.delete(user)
db.session.commit()
return {
"status": "ok",
"message": 'User deleted successfully'}, 200
except sa_exc.NoResultFound:
return {
"error": "Not Found",
"message": 'User not found'}, 404
# tests/test_user.py
def test_create_user(client):
""" 測試新增 user API """
response = client.post(f'{APP_PREFIX}/users', json={
"username": "New Phone",
"email": "[email protected]",
"password": "pass",
})
assert response.status_code == 201
assert response.json["status"] == "ok"
users = User.query.all()
assert len(users) == 1
def test_update_user(client, sample_user):
""" 測試更新 user API """
response = client.put(f'{APP_PREFIX}/users/{sample_user.id}', json={"username": 'hello', "email": "[email protected]"})
assert response.status_code == 200
assert response.json["status"] == "ok"
def test_delete_user(client, sample_user):
""" 測試 DELETE user API """
response = client.delete(f'{APP_PREFIX}/users/{sample_user.id}')
assert response.status_code == 200
assert response.json["status"] == "ok"
def test_deleted_get_user(client, sample_user):
""" 測試 DELETE user 後再查詢,應該回傳 404 """
client.delete(f'{APP_PREFIX}/users/{sample_user.id}')
response = client.get(f'/users/{sample_user.id}')
assert response.status_code == 404
pandas
numpy
Flask
flask_restful
flask_sqlalchemy
OpenBB
Metaflow
Best practices for RESTful web API design
python 3.11
flask 3.0.3
flask_restful 0.3.10
flask_sqlalchemy 3.1.1
pytest 8.4.1