はじめに

この記事は Muroran Institute of Technology Advent Calendar 2017 25日目の記事です。

Flaskを使ってREST APIを作りたいときに、同時にドキュメントとしてSwaggerも整備したいこと、あると思います。しかしドキュメントも書きつつAPIも書くとどっちかを更新したときにどっちかを更新し忘れるなどして、だんだん面倒になってきてしまいがちです。

そこで今回は簡単なToDo管理APIを例に、Flask-RESTPlusを使ってAPI定義とSwaggerによるドキュメント生成を一気に行ってしまう方法をご紹介します。

Python環境の準備

とりあえずPython環境を準備しておきましょう。PyenvとVirtualenvを使ってさくさくっと作っていきます。仮想環境名はrestplusにしてありますが別に何でもかまいません。今回は最新版であるPython3.6.4を用います。

$ mkdir restplus
$ cd restplus
$ pyenv virtualenv 3.6.4 restplus
$ pyenv local restplus

ファイルの準備

ではファイルやディレクトリを準備していきます。以下のような構造で準備してください。それぞれのファイルが何に使われるかは後述します。

restplus/
├── rest_api/
│   ├── apis/
│   │   └── __init__.py
│   ├── models/
│   │   └── __init__.py
│   ├── __init__.py
│   ├── app.py
│   └── settings.py
├── Dockerfile
├── docker-compose.yml
└── requirements.txt

Pythonライブラリの導入

requirements.txtの編集

requirements.txtには今回利用するPythonライブラリを列挙していきます。以下のように書いておきましょう。Flaskを使うのでORMにはSQL Alchemyを採用しました。また、データベースはMySQLを利用することにします。それに伴ってflask-sqlalchemypymysqlをインストールしておきます。

requirements.txt
flask
flask-restplus
flask-sqlalchemy
pymysql

ライブラリのインストール

編集できたら以下のコマンドでインストールしてしまいます。ぶっちゃけるとDockerで動かすので入れなくても良いといえばいいのですが、IDEなどで補完を効かせるために入れておきましょう。

$ pip install -r requirements.txt

コア部分の作成

ここまでできたらいよいよコーディングに入ります。Flaskを使ったWebアプリをいつも通りな感じで構築していきます。

APIエンドポイントの準備

apis/__init__.pyにAPIエンドポイントを登録する準備をします。Flask RESTPlusで用意されたAPIオブジェクトを初期化しておきましょう。

apis/__init__.py
from flask_restplus import Api

# API情報を指定して初期化
api = Api(
    title='Test API',
    version='1.0',
    description='Swaggerを統合したREST APIのサンプル'
)

ORMの準備

models/__init__.pyではSQL Alchemyの初期化を行います。このファイルにはDBのスキーマを定義していきます。

models/__init__.py
from flask_sqlalchemy import SQLAlchemy

# SQLAlchemyの初期化
db = SQLAlchemy()

アプリの諸設定

settings.pyにはアプリの設定を記述していきます。特にSQL接続情報などの記載は必須です。

settings.py
from os import environ

# デバッグモードを有効化
DEBUG = True

# Swaggerのデフォルト表示形式をListにする
SWAGGER_UI_DOC_EXPANSION = 'list'
# Validationの有効化
RESTPLUS_VALIDATE = True

# SQL接続情報
# コンテナ側に環境変数として渡すためこの形式で受け取る
SQLALCHEMY_DATABASE_URI = environ['MYSQL_URL']
SQLALCHEMY_TRACK_MODIFICATIONS = True

Flaskアプリの定義

ここまで準備できたらFlaskアプリを定義していきます。app変数にFlaskオブジェクトを入れて、それを引数で取り回しながらAPIエンドポイントやSQL Alchemyとの連携を行います。最後にrunメソッドを実行すればOKです。このとき、Dockerの中で実行されるためhostの指定を忘れないようにしましょう。

app.py
from flask import Flask

from rest_api import settings
from rest_api.apis import api
from rest_api.models import db

# Flask本体
app = Flask(__name__)


def configure_app(flask_app: Flask) -> None:
    # DB接続先情報やSwaggerの表示形式を指定
    flask_app.config['SQLALCHEMY_DATABASE_URI'] = settings.SQLALCHEMY_DATABASE_URI
    flask_app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = settings.SQLALCHEMY_TRACK_MODIFICATIONS
    flask_app.config['SWAGGER_UI_DOC_EXPANSION'] = settings.SWAGGER_UI_DOC_EXPANSION
    flask_app.config['RESTPLUS_VALIDATE'] = settings.RESTPLUS_VALIDATE


def initialize_app(flask_app: Flask) -> None:
    # FlaskへAPIやDB情報を登録
    configure_app(flask_app)
    api.init_app(flask_app)
    db.init_app(flask_app)
    db.create_all(app=flask_app)


def main() -> None:
    # Flaskを初期化して実行
    initialize_app(app)
    app.run(host='0.0.0.0', debug=settings.DEBUG)


if __name__ == '__main__':
    main()

Dockerの準備

Dockerfile

アプリをDockerに載せるためにDockerfileを書きます。MySQLの起動を待つためにDockerizeも一緒に仕込んでおきます。

FROM python:alpine3.6

WORKDIR /usr/src/app

ENV DOCKERIZE_VERSION v0.6.0

COPY requirements.txt ./
RUN pip install -U --no-cache-dir -r requirements.txt \
 && apk add --no-cache openssl \
 && wget https://github.com/jwilder/dockerize/releases/download/$DOCKERIZE_VERSION/dockerize-alpine-linux-amd64-$DOCKERIZE_VERSION.tar.gz \
 && tar -C /usr/local/bin -xzvf dockerize-alpine-linux-amd64-$DOCKERIZE_VERSION.tar.gz \
 && rm dockerize-alpine-linux-amd64-$DOCKERIZE_VERSION.tar.gz

COPY . .
CMD [ "python", "rest_api/app.py" ]

docker-compose.yml

DockerfileだけではMySQLとの統合が難しいのでdocker-compose.ymlも書いておきましょう。アプリ内部でのimportを使うため、該当ディレクトリをPYTHONPATH環境変数に渡してあげるのを忘れないようにしてください。また、MySQLの接続情報も渡してあげましょう。

version: '3'

services:
  restapi:
    build: .
    ports:
      - "5000:5000"
    environment:
      MYSQL_URL: 'mysql+pymysql://test:user_pass@mysql/test'
      PYTHONPATH: '/usr/src/app'
    links:
      - mysql
    entrypoint:
      - dockerize
      - -timeout
      - 60s
      - -wait
      - tcp://mysql:3306
    command: python /usr/src/app/rest_api/app.py

  mysql:
    image: mariadb:latest
    environment:
      MYSQL_ROOT_PASSWORD: 'root_pass'
      MYSQL_DATABASE: 'test'
      MYSQL_USER: 'test'
      MYSQL_PASSWORD: 'user_pass'
      command: mysqld --character-set-server=utf8 --collation-server=utf8_unicode_ci

起動テスト

ここまでできたら起動のテストを行います。docker-composeでさくさくっとやってしまいましょう。

$ docker-compose build
$ docker-compose up -d

起動し終わったら、http://localhost:5000/ にアクセスすると以下のような画面が表示されます。ここにだんだんとAPIの仕様書ができあがっていきます。

Flask_01.PNG

DBスキーマの定義

続いてデータベースのスキーマを定義していきます。models/__init__.pyにつらつらと書いていきます。ToDoに使うスキーマを簡単に定義します。models/__init__.pyに以下の記述を追記します。db変数に格納したSQLAlchemyオブジェクトを流用して、カラムやその型を定義します。このとき、primary_keyに設定したIntegerのカラムはオートインクリメントが適用されます。

models/__init__.py
from flask_sqlalchemy import SQLAlchemy

# SQLAlchemyの初期化
db = SQLAlchemy()


# Userスキーマの定義
class User(db.Model):
    __tablename__ = 'users'
    id = db.Column(db.Integer, nullable=False, primary_key=True)
    name = db.Column(db.Text, nullable=False)
    email = db.Column(db.Text, nullable=False)


# ToDoスキーマの定義
class ToDo(db.Model):
    __tablename__ = 'todo'
    id = db.Column(db.Integer, nullable=False, primary_key=True)
    user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
    title = db.Column(db.Text, nullable=False)
    description = db.Column(db.Text, nullable=False)

APIエンドポイントの定義

定義ファイルの作成

データベースの準備ができたらAPIエンドポイントを設計しましょう。apis/ディレクトリにtodo.pyuser.pyを新規作成し、そこに定義を書いていきます。

$ touch apis/todo.py apis/user.py

NamespaceとJSONモデルの定義

異なるAPIエンドポイントを区別するためにNamespaceを作成します。さらに、返したいJSONのモデルも定義しましょう。使ってもいないのにResourceをimportしていますが、この後すぐ使うのでここでimportしてしまいます。

Namespaceオブジェクトを生成する際の第1引数にはエンドポイントのプレフィックスを指定します。例えばtodo.pyではtodoと指定していますが、/todo以下でこれから定義するエンドポイントが呼べるようになります。作成したNamespaceは忘れずにAPIに登録してください。

JSONモデルの定義はDBのカラム名と同様の文字列をキーにした辞書で行います。各要素について必須かどうかや詳しい説明、例となる値を指定することができます。これらの情報は全てSwaggerドキュメントに反映されます。

apis/todo.py
from flask_restplus import Namespace, fields, Resource

# Namespaceの初期化
todo_namespace = Namespace('todo', description='ToDoのエンドポイント')

# JSONモデルの定義
todo = todo_namespace.model('ToDo', {
    'user_id': fields.Integer(
        required=True,
        description='ToDoを登録したユーザーID',
        example='0'
    ),
    'id': fields.Integer(
        required=True,
        description='ToDoのID',
        example='1'
    ),
    'title': fields.String(
        required=True,
        description='ToDoタイトル',
        example='起きる'
    ),
    'description': fields.String(
        required=True,
        description='ToDoの詳細',
        example='朝7時に起きたい'
    )
})
apis/user.py
from flask_restplus import Namespace, fields, Resource

# Namespaceの初期化
user_namespace = Namespace('users', description='ユーザー関連のエンドポイント')

# JSONモデルの定義
user = user_namespace.model('User', {
    'id': fields.Integer(
        required=True,
        description='',
        example='0'
    ),
    'name': fields.String(
        required=True,
        description='',
        example='Aruneko'
    ),
    'email': fields.String(
        required=True,
        description='',
        example='aruneko@example.com'
    )
})

エンドポイントの追加

NemespaceとJSONモデルが定義できたらいよいよエンドポイントを追加します。やり方は簡単で、Resourceクラスを継承したクラスを作ってHTTPメソッドと同名のメソッドを作成し、ルーティングに必要なアノテーションを付加するだけです。これらを踏まえて、apis以下のファイルの最終形は以下のようになります。

各メソッドの先頭にダブルクォートを6つ使った複数行コメントを差し込むことで、API説明をSwaggerに反映させることができます。1行目が簡易的な説明で、2行目以降はMarkdown記法を使った詳細な説明を記入できます。

marshal_withmarshal_list_withでは返ってくるJSONの形式を指定できます。前者は単体を、後者はリスト化したものを返します。先ほど定義したJSONモデルを引数にとってあげましょう。expectはその逆で、送って欲しいJSONのモデルを指定します。

apis/todo.py
from flask_restplus import Namespace, fields, Resource

from rest_api.apis import api
from rest_api.models import ToDo, db

# Namespaceの初期化と登録
todo_namespace = Namespace('todo', description='ToDoのエンドポイント')

# JSONモデルの定義
todo = todo_namespace.model('ToDo', {
    'user_id': fields.Integer(
        required=True,
        description='ToDoを登録したユーザーID',
        example='0'
    ),
    'id': fields.Integer(
        required=True,
        description='ToDoのID',
        example='1'
    ),
    'title': fields.String(
        required=True,
        description='ToDoタイトル',
        example='起きる'
    ),
    'description': fields.String(
        required=True,
        description='ToDoの詳細',
        example='朝7時に起きたい'
    )
})


@todo_namespace.route('/')
class ToDoList(Resource):
    # todoモデルを利用して結果をパースしてリストで返す
    @todo_namespace.marshal_list_with(todo)
    def get(self):
        """
        一覧取得
        """
        return ToDo.query.all()

    @todo_namespace.marshal_with(todo)
    @todo_namespace.expect(todo, validate=True)
    def post(self):
        """
        ToDo登録
        """
        # ちょっとやっかいなので実装はまた今度
        pass


@todo_namespace.route('/<int:todo_id>')
class ToDoController(Resource):
    # todoモデルを利用して結果をパースして単体で返す
    @todo_namespace.marshal_with(todo)
    def get(self, todo_id):
        """
        ToDo詳細
        """
        # ただし1個も見つからなかったら404を返す
        return ToDo.query.filter(ToDo.id == todo_id).first_or_404()

    def delete(self, todo_id):
        """
        ToDo削除
        """
        # 見つからなかったときの処理してないけど許して
        target_todo = ToDo.query.filter(ToDo.id == todo_id).first()
        db.session.delete(target_todo)
        return {'message': 'Success'}, 200
apis/user.py
from flask_restplus import Namespace, fields, Resource

from rest_api.apis import api

# Namespaceの初期化と登録
from rest_api.apis.todo import todo
from rest_api.models import User, db, ToDo

user_namespace = Namespace('users', description='ユーザー関連のエンドポイント')

# JSONモデルの定義
user = user_namespace.model('User', {
    'id': fields.Integer(
        required=True,
        description='',
        example='0'
    ),
    'name': fields.String(
        required=True,
        description='',
        example='Aruneko'
    ),
    'email': fields.String(
        required=True,
        description='',
        example='aruneko@example.com'
    )
})


@user_namespace.route('/')
class UserList(Resource):
    @user_namespace.marshal_list_with(user)
    def get(self):
        """
        ユーザー一覧
        """
        return User.query.all()

    @user_namespace.marshal_with(user)
    @user_namespace.expect(user)
    def post(self):
        """
        ユーザー登録
        """
        # ちょっとやっかいなので実装はまた今度
        pass


@user_namespace.route('/<int:user_id>')
class UserController(Resource):
    @user_namespace.marshal_with(user)
    def get(self, user_id):
        """
        ユーザー詳細
        """
        return User.query.filter(User.id == user_id).first_or_404()

    def delete(self, user_id):
        """
        ユーザー削除
        """
        target_user = User.query.filter(User.id == user_id).first()
        db.session.delete(target_user)
        return {'message': 'Success'}, 200


@user_namespace.route('/<int:user_id>/todo')
class ToDoByUser(Resource):
    @user_namespace.marshal_list_with(todo)
    def get(self, user_id):
        """
        ユーザーごとのToDo取得
        """
        return ToDo.query.filter(ToDo.user_id == user_id).all()

エンドポイントの登録

エンドポイントを作成し終わったら登録を行います。apis/__init__.pyに記入しましょう。add_namespaceメソッドを利用してください。

apis/__init__.py
from flask_restplus import Api

from rest_api.apis.todo import todo_namespace
from rest_api.apis.user import user_namespace

# API情報を指定して初期化
api = Api(
    title='Test API',
    version='1.0',
    description='Swaggerを統合したREST APIのサンプル'
)

api.add_namespace(todo_namespace)
api.add_namespace(user_namespace)

テスト起動

再びdocker-composeを使ってテスト起動してみます。

$ docker-compose build
$ docker-compose up -d

http://localhost:5000/ を確認してください。以下のように、APIドキュメントが表示されるはずです。

完成したAPIドキュメント

各項目をクリックすると、実際にAPIを試すことができるようになっています。また、先ほど指定したJSONモデルもしっかりと表示されていることがわかります。

Flask_03.PNG

さらに、curlなどでアクセスすればきちんとレスポンスが返ってくることも確認できます。今回はデータを入れていないので空のJSONだけが返ってきますけれども。

$ curl http://localhost:5000/todo/ 
[]

おわりに

ここまでFlask-RESTPlusを使ってSwaggerを統合したRESTfulなAPIを作成してきました。モデルの定義とドキュメント作成が同時に行えるので非常に便利です。

今回は紹介しませんでしたが、このほかにもリクエストのパースやエラーハンドリングなど、多彩な機能を取りそろえています。詳しいことは公式ドキュメントも参考にしてみてください。

最後になりましたが、 Muroran Institute of Technology Advent Calendar 2017 にご参加いただいた皆さん、ありがとうございました!