前面 28 天,我們是一塊一塊慢慢加上去的:
先寫 Flask,再換成 Flask-RESTX,接著接資料庫、加 Migration,最後加上錯誤處理和資安。
每天都只看一小塊,今天就把它們全部放在一起,看看完整的樣子。
功能:
GET /tasks 取得所有作業(可以用 completed 篩選)
POST /tasks 新增作業(需要金鑰)
GET /tasks/{id} 取得單筆作業
PUT /tasks/{id} 修改作業(需要金鑰)
DELETE /tasks/{id} 刪除作業(需要管理員金鑰)
用到的東西:
Flask + Flask-RESTX → API 和 Swagger 文件
Flask-SQLAlchemy → 資料庫
Flask-Migrate → 資料表結構管理
Input Validation → 擋掉不合法的資料
API Key → 身分驗證與權限
Error Handling → 統一的錯誤格式
app.py
import os
from functools import wraps
from flask import Flask, request
from flask_restx import Api, Resource, fields
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from sqlalchemy.exc import IntegrityError
from werkzeug.exceptions import HTTPException
# ---------- 設定 ----------
app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///tasks.db'
app.config['ERROR_404_HELP'] = False
db = SQLAlchemy(app)
migrate = Migrate(app, db, render_as_batch=True)
# ---------- 金鑰與身分 ----------
API_KEYS = {
os.environ.get('USER_API_KEY', 'user-key-12345'): 'user',
os.environ.get('ADMIN_API_KEY', 'admin-key-67890'): 'admin',
}
authorizations = {
'apikey': {
'type': 'apiKey',
'in': 'header',
'name': 'X-API-KEY',
}
}
api = Api(
app,
title='Task API',
version='1.0',
description='30 天鐵人賽練習用的作業管理 API',
authorizations=authorizations,
)
def require_api_key(role=None):
"""檢查 X-API-KEY,role 可以指定需要的身分"""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
key = request.headers.get('X-API-KEY')
if not key or key not in API_KEYS:
api.abort(401, "請提供有效的 X-API-KEY")
if role and API_KEYS[key] != role:
api.abort(403, "你沒有權限執行這個操作")
return func(*args, **kwargs)
return wrapper
return decorator
# ---------- Model(資料表) ----------
class Task(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False)
completed = db.Column(db.Boolean, nullable=False, default=False)
due_date = db.Column(db.String(20))
priority = db.Column(db.Integer, nullable=False, default=0)
# ---------- api.model(API 的輸入輸出) ----------
task_input = api.model('TaskInput', {
'name': fields.String(required=True, description='作業名稱', example='洗碗'),
'completed': fields.Boolean(required=True, description='是否完成', example=False),
'due_date': fields.String(description='截止日期 YYYY-MM-DD', example='2026-10-15'),
'priority': fields.Integer(description='優先度,數字越大越優先', example=0),
})
task_response = api.model('TaskResponse', {
'id': fields.Integer(readonly=True, description='作業編號'),
'name': fields.String(description='作業名稱'),
'completed': fields.Boolean(description='是否完成'),
'due_date': fields.String(description='截止日期'),
'priority': fields.Integer(description='優先度'),
})
# ---------- 共用函式 ----------
def find_task(task_id):
"""找一筆作業,找不到就回 404"""
task = db.session.get(Task, task_id)
if task is None:
api.abort(404, f"找不到 id 為 {task_id} 的作業")
return task
def validate_task_data(data):
"""檢查輸入資料,回傳整理過的資料"""
name = data.get("name")
if not isinstance(name, str) or not name.strip():
api.abort(400, "name 不可以是空白")
name = name.strip()
if len(name) > 100:
api.abort(400, "name 最多只能 100 個字")
completed = data.get("completed", False)
if not isinstance(completed, bool):
api.abort(400, "completed 必須是 true 或 false")
due_date = data.get("due_date")
if due_date is not None and not isinstance(due_date, str):
api.abort(400, "due_date 必須是字串,格式為 YYYY-MM-DD")
priority = data.get("priority", 0)
if not isinstance(priority, int) or isinstance(priority, bool):
api.abort(400, "priority 必須是整數")
if priority < 0:
api.abort(400, "priority 不可以小於 0")
return {
"name": name,
"completed": completed,
"due_date": due_date,
"priority": priority,
}
# ---------- 錯誤處理 ----------
@api.errorhandler(IntegrityError)
def handle_integrity_error(error):
"""資料不符合資料庫規則"""
db.session.rollback()
return {"message": "資料不符合規則,請檢查輸入的內容"}, 400
@api.errorhandler(Exception)
def handle_unexpected_error(error):
"""未預期的錯誤"""
if isinstance(error, HTTPException):
return {"message": error.description}, error.code
db.session.rollback()
app.logger.exception("未預期的錯誤")
return {"message": "伺服器發生錯誤,請稍後再試"}, 500
# ---------- API ----------
@api.route('/tasks')
class TaskList(Resource):
@api.param('completed', '篩選是否完成,填 true 或 false')
@api.marshal_list_with(task_response)
def get(self):
"""取得所有作業"""
stmt = db.select(Task)
completed = request.args.get('completed')
if completed is not None:
stmt = stmt.filter_by(completed=(completed.lower() == 'true'))
return db.session.scalars(stmt).all()
@api.doc(security='apikey')
@api.expect(task_input, validate=True)
@api.marshal_with(task_response, code=201)
@api.response(400, '輸入資料有問題')
@api.response(401, '沒有提供有效的金鑰')
@require_api_key()
def post(self):
"""新增一筆作業"""
data = validate_task_data(api.payload)
task = Task(
name=data["name"],
completed=data["completed"],
due_date=data["due_date"],
priority=data["priority"],
)
try:
db.session.add(task)
db.session.commit()
except IntegrityError:
db.session.rollback()
api.abort(400, "資料不符合規則,請檢查輸入的內容")
return task, 201
@api.route('/tasks/<int:task_id>')
@api.param('task_id', '作業編號')
@api.response(404, '找不到作業')
class TaskDetail(Resource):
@api.marshal_with(task_response)
def get(self, task_id):
"""取得指定編號的作業"""
return find_task(task_id)
@api.doc(security='apikey')
@api.expect(task_input, validate=True)
@api.marshal_with(task_response)
@api.response(400, '輸入資料有問題')
@api.response(401, '沒有提供有效的金鑰')
@require_api_key()
def put(self, task_id):
"""修改指定編號的作業"""
task = find_task(task_id)
data = validate_task_data(api.payload)
task.name = data["name"]
task.completed = data["completed"]
task.due_date = data["due_date"]
task.priority = data["priority"]
db.session.commit()
return task
@api.doc(security='apikey')
@api.response(204, '刪除成功')
@api.response(403, '需要管理員權限')
@require_api_key(role='admin')
def delete(self, task_id):
"""刪除指定編號的作業(只有管理員可以)"""
task = find_task(task_id)
db.session.delete(task)
db.session.commit()
return '', 204
if __name__ == '__main__':
app.run(debug=True)
python -m venv .venv
.venv\Scripts\Activate.ps1
pip install Flask==3.1.3 flask-restx==1.3.2 Flask-SQLAlchemy==3.1.1 Flask-Migrate==4.1.0
也可以寫成 requirements.txt:
Flask==3.1.3
flask-restx==1.3.2
Flask-SQLAlchemy==3.1.1
Flask-Migrate==4.1.0
然後用:
pip install -r requirements.txt
因為已經改用 Flask-Migrate 管理(Day 23),所以不是 create_all(),而是:
flask db init
flask db migrate -m "create task table"
flask db upgrade
python app.py

完整版的 Swagger:五個 API、中文說明、右上角 Authorize、需要金鑰的旁邊有鎖頭
把前面學過的東西全部驗證一次:
{
"name": "寫 Day 29",
"completed": false,
"due_date": "2026-10-15",
"priority": 2
}
→ 201
這十個情境前面幾天都分別測過了,今天是確認它們湊在一起也沒有互相打架。
把程式對照回前面幾天,其實整份 app.py 就是這 29 天的整理:
Flask、@app.route → Day 7、Day 8
回傳 JSON → Day 9
Api、Resource、@api.route → Day 11
Swagger → Day 12
api.model、@api.expect、api.payload → Day 13
CRUD、路徑參數、api.abort → Day 14、Day 15
marshal、docstring 文件 → Day 16
資料庫、ORM → Day 17~Day 19
Model、db.session → Day 20~Day 22
completed 篩選(request.args)→ Day 22 的小延伸
Migration、server_default → Day 23
錯誤處理、狀態碼、ERROR_404_HELP → Day 24
Input Validation、白名單 → Day 25
API Key、401/403、@wraps → Day 26
參數化查詢(ORM 幫我們做) → Day 27、Day 28
每一個 decorator 背後都是一天的內容,現在看起來就比較有感覺了。
現在整個程式都寫在一個 app.py 裡,兩百多行了。
實際的專案通常會拆開,大概會像這樣:
project/
├── app.py # 建立 app、註冊設定和 API
├── config.py # 設定
├── extensions.py # db = SQLAlchemy()、migrate = Migrate()
├── models.py # Task 等 Model
└── apis/
└── task.py # Task 的 Resource
Flask-RESTX 有提供 Namespace,可以把一組相關的 API 收在一起:
# apis/task.py
from flask_restx import Namespace, Resource
ns = Namespace('tasks', description='作業相關的 API')
@ns.route('')
class TaskList(Resource):
...
# app.py
api.add_namespace(ns, path='/tasks')
這樣 Swagger 上也會依照 Namespace 分組,API 多的時候看起來會清楚很多。
這部分就先不展開了,有興趣可以自己試試看。
今天把 30 天學到的東西全部放進同一支程式,完成了一個具備這些能力的 API:
有 API 文件可以直接測試
資料真的存在資料庫,重開不會不見
資料表結構的改變有 migration 紀錄
會檢查輸入的資料
有身分驗證和權限區分
出錯的時候會回傳正確的狀態碼和看得懂的訊息
明天就是最後一天了,來做個總回顧,也聊聊這 30 天遇到的問題和心得。