シンプルな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にあげた。