シンプルなToDoリストのWeb APIを作る。 今までWSGIの仕様のみ、Werkzeug の2通りで実装したが、今回はFlaskといくつかのライブラリを使う。使うのは以下の通り。
- Flask: WSGIアプリフレームワーク。
- peewee: ORMライブラリ。
- marshmallow: データの変換やvalidationをするためのライブラリ。
Flaskとは
WSGIのWebアプリを作るためのフレームワーク。 フレームワークであるため、Flaskが用意した作法に従ってコードを書くことで比較的手軽にWebアプリが作成できる。 前回との比較でいうならば、例えばルーティングの仕組みをプログラマが書く必要はない。これはFlaskに備わっている。
Djangoのようなフレームワークとは違って、持っている機能は少ない。必要に応じて外部ライブラリを組み合わせる。
例えば、Djangoではデフォルトでデータベースの仕組みが内蔵されているが、Flaskにはない。その代わりに、
データベースのライブラリとしてsqlite3
やSQLAlchemy
、peewee
など、好きなものを用いれば良い。
ToDoリストAPIの仕様
今回ToDoのデータは以下キーと値を持つJSONデータとする。
key | value |
---|---|
id | ID |
content | ToDoの内容 |
created_at | 作成日時 (ISO8601) |
updated_at | 更新日時 (ISO8601) |
APIの仕様は以下の通り。
URL | Method | 説明 | 返却値 |
---|---|---|---|
/todo/ | GET | 全てのToDoを取得。 | ToDoのデータのリスト |
/todo/ | POST | ToDoを作成。 | なし (LocationヘッダにそのToDoへのURLを乗せる) |
/todo/<todo_id> | GET | todo_id のidを持つToDoを取得。 | ToDoのデータ |
/todo/<todo_id> | PUT | todo_id のidを持つToDoを変更。 | なし |
/todo/<todo_id> | DELETE | todo_id のidを持つToDoを削除 | なし |
データはSQLiteで管理する。
雛形
todo_list
ディレクトリを作成。todo_list/__init__.py
を以下のようにする。
|
|
export
コマンドで環境変数を設定する。
% export FLASK_APP=todo_list % export FLASK_ENV=development
flask run
でサーバーが起動する。
% flask run
curl
コマンドで試しにアクセスしてみる。
% curl localhost:5000 Hello, World
雛形 - 解説
まず、Flask(__name__)
でWSGIアプリを作ることができる。アプリをapp
としたとき、@app.route
デコレータでURLに対応する関数を登録することができる。
以下は、URL /
に対して関数hello
を登録している。よって、URL /
にアクセスされたときに、Hello, World
というレスポンスが返ってくるようになる。
|
|
HTMLデータやJSONなどの、アプリのユーザーが「見る部分」のことをviewという。
そしてhello
のような、@app.route
で登録される関数をview関数という。
上のコードでは'Hello, World'
を返すviewをview関数hello
として定義していることになる。
view関数の書き方についてはドキュメントのQuickstartを参照。
例えば文字列ではなくdict
を返すと、JSONデータとしてレスポンスが送られる。
create_app
関数はWSGIアプリを返す関数である。この関数内で、WSGIアプリに関する様々な設定をする。
このようなパターンはApplication Factoriesと呼ばれる。
後でここにデータベースのパスの設定など、色々書いていく。
環境変数FLASK_APP
には、WSGIアプリがあるモジュールを指定している。
flask run
コマンドは、FLASK_APP
に設定されたモジュールのcreate_app
関数を元にWSGIアプリを作成し、開発用サーバーを起動する。
環境変数FLASK_ENV
にdevelopment
を設定すると、自動リロードがONになったり、例外発生時にデバッグ用のレスポンスを送ったりしてくれる。
Blueprintの利用
まず、Blueprintを作成する。todo_list/todo.py
を作成し、ひとまず内容を以下の通りにする。
|
|
WSGIアプリにBlueprintを登録する。todo_list/__init__.py
の内容を以下の通りにする。
|
|
curl
で確認。
% curl localhost:5000/todo/ get_all % curl localhost:5000/todo/1/ get: 1 % curl -X POST -H "Content-Type: application/json" localhost:5000/todo/ -d '{"content": "部屋の掃除"}' post: {'content': '部屋の掃除'} % curl -X PUT -H "Content-Type: application/json" localhost:5000/todo/1/ -d '{"content": "部屋の掃除"}' put: 1, {'content': '部屋の掃除'} % curl -X DELETE localhost:5000/todo/1/ delete: 1
Blueprint - 解説
Blueprintとは、viewをグループ化する仕組みである。 これによって、viewの使い回しができたり、同じprefixのURLでまとめたりできるようになる (参考)。
上のコードでは、todo
というBlueprintを作り、それをbp
とした。
使い方は@app.route
と同じく、@bp.route
のように用いる。
WSGIアプリにBlueprintを登録するには、register_blueprint
メソッドを使う。
url_prefix
引数を/todo
とすることで、対応するBlueprintのURLのprefixを/todo
に統一する。
peeweeを使ったToDoモデルの作成
todo_list/models/__init__.py
とtodo_list/models/todo.py
を作成。前者は空のままにし、後者は内容を以下のようにする。
|
|
peeweeを使ったToDoモデルの作成 - 解説
peeweeはORMである。つまり、PythonのオブジェクトとRDBMS(上ではSQLite)のデータを相互に変換するためのライブラリである。
ORMを使う1つの利点は、SQL文を書かずに済むことである。
データはModel
というクラスとして管理する。Model
を介してRDBMSのデータを操作する。
上のコードではTodo
というモデルを作成し、その属性としてcontent, created_at, updated_at
を用意している。
これらはそれぞれ、テキスト、時刻、時刻の型を持つ。id
は自動で追加される。
created_at
には、Todo
のインスタンス作成時に時刻が記録されるように、default
引数を設定している。
updated_at
について、save
メソッドが呼ばれる度に時刻が記録されるように、save()
メソッドをオーバーライドしている
(参考)。
peeweeを使ったToDoモデルの作成 - コンソール上でのテスト
うまくTodo
が定義されているかテストしてみる。
まず、関連のモジュールをimportする。
% python3 >>> from todo_list.models.todo import Todo >>> from peewee import SqliteDatabase
次に、データベースを作成する。テストのため、データベースはメモリ上で動かす。
データベースにTodo
クラスを紐づけた後、create_table
でTodo
のテーブルを作成する。
>>> db = SqliteDatabase(':memory:') >>> db.bind([Todo]) >>> db.create_table([Todo])
実はこのあたりはpeeweeのQuickstartとやり方が異なる。
Quickstartでは、Todo
クラスのMeta
内部クラスにデータベースを設定している。
ではなぜ今回はbind
を使ったのかと言うと、Flaskのアプリを作る上での都合である。詳しくは後述する。
bind
を使った話はSetting the database at run-timeに載っている。
Todo.create
でTodo
のインスタンスを作成。select
やget
などのクラスメソッドで、データの取得が行える。save
メソッドでデータの更新ができる。
>>> todo = Todo.create(content='部屋の掃除') >>> list(Todo.select()) [<Todo: 1>] >>> todo.content = '犬の散歩' >>> todo.save() 1 >>> Todo.get(Todo.id == 1).content '犬の散歩'
peeweeでは便利な機能をplayhouseとして提供しているようで、
例えばplayhouse.shortcuts
のmodel_to_dict
関数は、Model
をdict
に変換してくれる。
>>> from playhouse.shortcuts import model_to_dict >>> model_to_dict(todo) {'id': 1, 'content': '犬の散歩', 'created_at': datetime.datetime(2021, 6, 2, 15, 45, 15, 247438), 'updated_at': datetime.datetime(2021, 6, 2, 15, 45, 39, 839677)}
ただし、dict
への変換はmarshmallowに任せるので、model_to_dict
はこの後使わない。
marshmallowを使ったデータの変換とvalidation
todo_list/models/todo.py
を以下のようにする。
|
|
marshmallowを使ったデータの変換とvalidation - 解説
marshmallowは、JSONデータとPythonオブジェクトを相互に変換する機能を提供する。
その変換のルールはSchema
クラスで定義する。
上のTodoSchema
は、以下の変換のルールを定義する。
id
は整数値content
は文字列。必須項目なので、required=True
を設定している。create_at
、update_at
は日付時刻。DateTime
は時刻を文字列として変換する。その形式のデフォルトはISO8601(参考)。
スキーマのインスタンスをtodo_schema
とする。todo_schema.loads
でJSONからdict、todo_schema.dumps
でPythonオブジェクトからJSONに変換する。
変換の際に、フィールドが存在しなかったり、型が合わなかったりした場合は、ValidationError
例外を投げる。
marshmallowを使ったデータの変換とvalidation - コンソール上でのテスト
再びPythonでテストする。
まずはデータベースを作る。
>>> from todo_list.models.todo import Todo >>> from peewee import SqliteDatabase >>> db = SqliteDatabase(':memory:') >>> db.bind([Todo]) >>> db.create_table([Todo])
Todo
からdict
への変換はdump
で行う。JSONに変換するdumps
というのもある。
>>> todo = Todo.create(content='部屋の掃除') >>> from todo_list.models.todo import todo_schema >>> todo_schema.dump(todo) {'created_at': '2021-06-02T16:32:55.820672', 'updated_at': '2021-06-02T16:32:55.820739', 'content': '部屋の掃除', 'id': 1}'
dict
のvalidationはload
で行う。不正な値をload
するとValidationError
が発生することも確認する。
>>> todo_schema.load({'content': '部屋の掃除'}) {'content': '部屋の掃除'} >>> todo_schema.load({'foo': 1}) ... marshmallow.exceptions.ValidationError: {'content': ['Missing data for required field.'], 'foo': ['Unknown field.']} >>> todo_schema.load({'content': 1}) ... marshmallow.exceptions.ValidationError: {'content': ['Not a valid string.']}
Flaskとデータベースの連携
todo_list/db.py
を作成し、内容を以下のようにする。
|
|
todo_list/app.py
を以下のようにする。
|
|
todo_list/todo.py
に以下の内容を追記する。
|
|
Flaskとデータベースの連携 - 解説
Flaskでは、WSGIアプリの設定情報を管理するためにapp.config
が提供されている。
app.config
の設定方法はいくつかある。
外部のPythonファイルから読み込んだり(app.config.from_pyfile
)、
dict
から読み込んだり(app.config.from_mapping
)できる。
これをうまく利用すれば、例えば本番環境と開発環境、テスト環境で異なる設定を適用することができる。
ここでは、データベースのパスをapp.config['DB_PATH']
に格納しておく。
Flaskにはinstance folderというものがある。
これは、例えばGitでバージョン管理してほしくないもの(データベースファイルや設定ファイルなど)を入れるディレクトリである。
そのパスはapp.instance_path
で取得でき、デフォルトでは「アプリケーションの1つ上のディレクトリ」となる。
今回の場合はtodo_list
と同じ階層あるinstance
というディレクトリをinstance folderとみなす。
instance folderは自動で作られることはないため、その作成をmake_instance_folder
に任せている。
データベースの設定はdb.init_app
関数に任せる。
db.init_app
では、まずデータベースの設定をdb.init
で行う。その後db.bind
でTodo
を紐付けている。
SQLiteDatabase
の引数は本来データベースのパスを指定するはずだが、これをNone
とすることで、それを後回しにしている。
そして、そのパスをdb.init(app.config['DB_PATH'])
で設定している
(参考)。
前前節で「TodoクラスのMeta内部クラスにデータベースを設定しないのはFlaskアプリを作るうえでの都合」と述べたが、
その理由はこのようにデータベースのパスをapp.config
で設定したかったからだ。
app.teardown_appcontext
では、リクエストが送られてきて、それに対して何かしらの処理が終わった後に呼び出される関数を登録する。
ここでは、データベースがもし接続されていたら閉じる処理を行う。
/todo/...
のURLにアクセスされた時は必ずにデータベースに接続するので、その接続処理をconnect_db
で行う。@bp.before_request
デコレータを使うと、
リクエストの前に行われる処理を設定することができる。
teardown_appcontext
やbefore_request
で、リクエスト処理の前後に新たな処理を付け足せることが見て取れるだろう。
こういう機能が提供されていることはありがたい。
データベースの初期化
todo_list/db.py
を以下のようにする。
|
|
以下のコマンドを実行すると、データベースが初期化される。
% flask init-db Initizlized the database.
データベースの初期化 - 解説
Flaskでは自作のコマンドを作る機能が備わっている。コマンドに対応する関数を登録するメソッドはapp.cli.add_command
で行う。
登録する関数に対して@click.command
デコレータを使うと、コマンド名を設定できる。上のコードではその名前をinit-db
にしているため、
flask init-db
でコマンドが実行される。@with_appcontext
で包むと、WSGIアプリが生成された後にこのコマンドが実行されることを保証してくれる。
今回の例ではinit_app
が先に実行されていないとデータベースに接続できないため、このデコレータを用いている。
コマンドの内容は、単にTodo
のテーブルを削除して、新たに作り直しているだけである。
drop_tables
では、SQLのDROP
文が実行される。これはデフォルトでIF EXISTS
句をつけてくれる。この設定はsafe
引数で変えられる。
同様に、create_tables
ではデフォルトでIF NOT EXISTS
をつけてくれる。
ToDoのレスポンスの実装
todo_list/todo.py
を以下のようにする。
|
|
todo_list/models/todo.py
を以下のようにする。
|
|
todo_list/__init__.py
に1行追加する。
|
|
ToDoのレスポンスの実装 - 解説
レスポンスボディを取得したり、Todo
を返してデータベースを操作したり、それをJSONに変換して送ったりしているだけ。
get
ではToDo、get_all
では、ToDoのリストを返す必要がある。そのために、dump_todo
関数を定義している。
many
引数で、変換後の値がリストか否かを制御する。
get, put, delete
では、idがtodo_id
に一致するToDoを探す処理を行っている。もし見つからなければ404 NotFoundを返すように、
クラスメソッドget_or_404
を定義している。
レスポンスボディが正しい形式であるかを判定するために、load_todo_or_400
を定義している。
もし正しい形式でなければ、400 BadRequestを返す。
レスポンスで日本語がエスケープされないようにするためには、app.config['JSON_AS_ASCII']
をFalse
に設定する。
レスポンスとしてjsonify
関数を返すと、MIMEタイプをapplication/json
に設定してくれる。
なので、todo_schema.dumps
を使わずにあえてjsonify
を使っている。
pytestによるテスト
Werkzeugと同様、Flaskにもテスト用のクライアントが用意されている。これについて軽く書いておく。
pytestによるテスト - 準備
まず、アプリ側でテスト用の設定を受け付けられるようにする。todo_list/__init__.py
のcreate_app
関数を以下のように追記する。
|
|
引数にtest_config
が設定されていた場合、app.config.from_mapping
メソッドでそれをapp.config
に割り当てる。
tests
ディレクトリを作成し、tests/conftest.py
を作成。内容を以下のようにする。
これはFlaskのTutorialに書いてある内容とほぼ同じである。
|
|
app
のfixtureをまず作成し、それを使ってclient
とrunner
のfixtureを作成する。
client
はテスト用のクライアントを返す。これはもちろんアプリのテストに用いる。
runner
はコマンドのテストを行うために用いる。
テスト用のアプリでは、データベースを一時ファイルとして作成するため、その初期処理をapp
fixtureで行っている。
pytestによるテスト - コマンド
tests/test_db.py
を作成し、内容を以下のようにする。
これはFlaskのTutorialとほぼ同じ。
|
|
runner.invoke
でコマンドを実行し、その出力結果をresult.output
で取得できる。
ちゃんとInitialized
で始まるメッセージが出力されているかをテストしている。
monkeypatch
は、pytestで標準で用意されているfixtureである。
init_db
が呼ばれたかどうかをテストするために、monkeypatch
を使ってtodo_init_.db.init_db
をfake_init_db
にすり替え、
fake_init_db
が呼ばれたかどうかのフラグをRecorder
クラスで管理している。
pytestによるテスト - ToDo
tests/test_todo.py
を作成し、内容を以下のようにする。
本当はいろいろテストしなければならないのだが、ここでは一例のみ示す。
|
|
client
はWerkzeugのものと使い方は同じ。
例えばclient.get
やclient.post
で、それぞれGETメソッド、POSTメソッドを送ることができる。
ソースコード
ソースコードはGitHubのRepositryにあげた。