概要

前回Flaskを利用したRESTFulなAPI環境を構築したのですが、親子関係のあるリソースを追加して、ネストしたJSONを返す方法をまとめてみました。

前記事: PythonのFlaskでMySQLを利用したRESTfulなAPIをDocker環境で実装する
https://cloudpack.media/43980

これが

実装前

> curl http://localhost:5000/hoges/3a401c04-44ff-4d0c-a46e-ee4b9454d872

{
    "updateTime": "2018-10-13T10:16:06",
    "id": "3a401c04-44ff-4d0c-a46e-ee4b9454d872",
    "state": "hoge",
    "name": "hoge",
    "createTime": "2018-10-13T10:16:06"
}

こうなれば、ゴールです。

実装後

> curl http://localhost:5000/hoges/3a401c04-44ff-4d0c-a46e-ee4b9454d872

{
    "updateTime": "2018-10-13T10:16:06",
    "id": "3a401c04-44ff-4d0c-a46e-ee4b9454d872",
    "state": "hoge",
    "name": "hoge",
    "createTime": "2018-10-13T10:16:06",
    "parent": {
        "updateTime": "2018-10-13T10:16:06",
        "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
        "name": "foo",
        "createTime": "2018-10-13T10:16:06"
    }
}

今回のソースはGitHubにアップしているので、よければご参考ください。
https://github.com/kai-kou/flask-mysql-restful-api-on-docker/tree/feature/use-sqlalchemy-relationship

環境構築

前記事で構築した環境を利用します。

PythonのFlaskでMySQLを利用したRESTfulなAPIをDocker環境で実装する
https://cloudpack.media/43980

ソースを取得して、DBにテーブルを追加します。

> git clone https://github.com/kai-kou/flask-mysql-restful-api-on-docker.git
> cd flask-mysql-restful-api-on-docker
> docker-compose up -d
> docker-compose exec api flask db upgrade

動作確認しておきます。

> curl -X POST http://localhost:5000/hoges \
  -H "Content-Type:application/json" \
  -d "{\"name\":\"hoge\",\"state\":\"hoge\"}"
{
    "createTime": "2018-11-02T13:16:26",
    "id": "691d89de-fd34-41c1-b212-036cacca742e",
    "name": "hoge",
    "state": "hoge",
    "updateTime": "2018-11-02T13:16:26"
}

> curl -X DELETE http://localhost:5000/hoges/691d89de-fd34-41c1-b212-036cacca742e

はい。

リソースを追加する

hoges リソースに紐付くparents リソースを追加します。

parents が親、hoges が子として、1:多となります。
こんな感じです。

@startuml
entity "parents" {
    + id [PK]
    ==
    name
}

entity "hoges" {
    + id
    ==
    # parent_id [FK]
    name
    state
}
parents --|{ hoges
@enduml

parentsリソースの追加

まずはリソースを追加するのにModelとAPIを実装します。

> touch src/models/parent.py
> touch src/apis/parent.py

src/models/parent.py

from datetime import datetime

from flask_marshmallow import Marshmallow

from flask_marshmallow.fields import fields

from sqlalchemy_utils import UUIDType

from src.database import db

import uuid

ma = Marshmallow()


class ParentModel(db.Model):
  __tablename__ = 'parents'

  id = db.Column(UUIDType(binary=False), primary_key=True, default=uuid.uuid4)
  name = db.Column(db.String(255), nullable=False)

  createTime = db.Column(db.DateTime, nullable=False, default=datetime.now)
  updateTime = db.Column(db.DateTime, nullable=False, default=datetime.now, onupdate=datetime.now)

  def __init__(self, name):
    self.name = name


  def __repr__(self):
    return ''.format(self.id, self.name)


class ParentSchema(ma.ModelSchema):
  class Meta:
    model = ParentModel

  createTime = fields.DateTime('%Y-%m-%dT%H:%M:%S')
  updateTime = fields.DateTime('%Y-%m-%dT%H:%M:%S')

src/apis/parent.py

from flask_restful import Resource, reqparse, abort

from flask import jsonify

from src.models.parent import ParentModel, ParentSchema

from src.database import db


class ParentListAPI(Resource):
  def __init__(self):
    self.reqparse = reqparse.RequestParser()
    self.reqparse.add_argument('name', required=True)
    super(ParentListAPI, self).__init__()


  def get(self):
    results = ParentModel.query.all()
    jsonData = ParentSchema(many=True).dump(results).data
    return jsonify({'items': jsonData})


  def post(self):
    args = self.reqparse.parse_args()
    parent = ParentModel(args.name)
    db.session.add(parent)
    db.session.commit()
    res = ParentSchema().dump(parent).data
    return res, 201


class ParentAPI(Resource):
  def __init__(self):
    self.reqparse = reqparse.RequestParser()
    self.reqparse.add_argument('name')
    super(ParentAPI, self).__init__()


  def get(self, id):
    parent = db.session.query(ParentModel).filter_by(id=id).first()
    if parent == None:
      abort(404)

    res = ParentSchema().dump(parent).data
    return res


  def put(self, id):
    parent = db.session.query(ParentModel).filter_by(id=id).first()
    if parent == None:
      abort(404)
    args = self.reqparse.parse_args()
    for name, value in args.items():
      if value is not None:
        setattr(parent, name, value)
    db.session.add(parent)
    db.session.commit()
    return None, 204


  def delete(self, id):
    parent = db.session.query(ParentModel).filter_by(id=id).first()
    if parent is not None:
      db.session.delete(parent)
      db.session.commit()
    return None, 204

app.pyにもリソースを追加しておきます。

src/app.py

from flask import Flask, jsonify

from flask_restful import Api

from src.database import init_db

from src.apis.parent import ParentListAPI, ParentAPI

from src.apis.hoge import HogeListAPI, HogeAPI


def create_app():

  app = Flask(__name__)
  app.config.from_object('src.config.Config')

  init_db(app)

  api = Api(app)
  # parentsリソースを追加
  api.add_resource(ParentListAPI, '/parents')
  api.add_resource(ParentAPI, '/parents/')

  api.add_resource(HogeListAPI, '/hoges')
  api.add_resource(HogeAPI, '/hoges/')

  return app


app = create_app()

hogesリソースのModelとAPIの修正

hoges からparents リソースが参照したいので、hoges 側にリレーションの実装を追加します。

src/models/hoge.py

from datetime import datetime

from flask_marshmallow import Marshmallow

from flask_marshmallow.fields import fields

from sqlalchemy_utils import UUIDType

from src.database import db

import uuid

ma = Marshmallow()

from .parent import ParentModel, ParentSchema


class HogeModel(db.Model):
  __tablename__ = 'hoges'

  id = db.Column(UUIDType(binary=False), primary_key=True, default=uuid.uuid4)
  name = db.Column(db.String(255), nullable=False)
  state = db.Column(db.String(255), nullable=False)
  # 追加
  parent_id = db.Column(UUIDType(binary=False), db.ForeignKey('parents.id'), nullable=False)
  parent = db.relationship("ParentModel", backref='hoges')

  createTime = db.Column(db.DateTime, nullable=False, default=datetime.now)
  updateTime = db.Column(db.DateTime, nullable=False, default=datetime.now, onupdate=datetime.now)

  # parent_idを追加
  def __init__(self, name, state, parent_id):
    self.name = name
    self.state = state
    self.parent_id


  def __repr__(self):
    return ''.format(self.id, self.name)


class HogeSchema(ma.ModelSchema):
  class Meta:
    model = HogeModel

  createTime = fields.DateTime('%Y-%m-%dT%H:%M:%S')
  updateTime = fields.DateTime('%Y-%m-%dT%H:%M:%S')
  # parentがネストされるようにする
  parent = ma.Nested(ParentSchema)

追加した箇所を一部抜粋してみます。

外部キーの指定と、relationship を利用して、hoge.parent と参照できるようにしています。relationship の利用方法は下記が参考になります。

SQLAlchemy 1.2 Documentation – Basic Relationship Patterns
https://docs.sqlalchemy.org/en/latest/orm/basic_relationships.html

 parent_id = db.Column(UUIDType(binary=False), db.ForeignKey('parents.id'), nullable=False)
  parent = db.relationship("ParentModel", backref='hoges')

HogeSchemaparents リソースがネストされるように指定しています。

 parent = ma.Nested(ParentSchema)

hoges のAPI定義にparent_id を追加しておきます。

src/apis/hoge.py

from flask_restful import Resource, reqparse, abort

from flask import jsonify

from src.models.hoge import HogeModel, HogeSchema

from src.database import db


class HogeListAPI(Resource):
  def __init__(self):
    self.reqparse = reqparse.RequestParser()
    self.reqparse.add_argument('name', required=True)
    self.reqparse.add_argument('state', required=True)
    # 追加
    self.reqparse.add_argument('parent_id', required=True)
    super(HogeListAPI, self).__init__()


  def get(self):
    results = HogeModel.query.all()
    jsonData = HogeSchema(many=True).dump(results).data
    return jsonify({'items': jsonData})


  def post(self):
    args = self.reqparse.parse_args()
    # parent_id追加
    hoge = HogeModel(args.name, args.state, args.parent_id)
    db.session.add(hoge)
    db.session.commit()
    res = HogeSchema().dump(hoge).data
    return res, 201


class HogeAPI(Resource):
  def __init__(self):
    self.reqparse = reqparse.RequestParser()
    self.reqparse.add_argument('name')
    self.reqparse.add_argument('state')
    # 追加
    self.reqparse.add_argument('parent_id')
    super(HogeAPI, self).__init__()


  def get(self, id):
    hoge = db.session.query(HogeModel).filter_by(id=id).first()
    if hoge == None:
      abort(404)

    res = HogeSchema().dump(hoge).data
    return res


  def put(self, id):
    hoge = db.session.query(HogeModel).filter_by(id=id).first()
    if hoge == None:
      abort(404)
    args = self.reqparse.parse_args()
    for name, value in args.items():
      if value is not None:
        setattr(hoge, name, value)
    db.session.add(hoge)
    db.session.commit()
    return None, 204


  def delete(self, id):
    hoge = db.session.query(HogeModel).filter_by(id=id).first()
    if hoge is not None:
      db.session.delete(hoge)
      db.session.commit()
    return None, 204

マイグレーションファイルの生成と手直し

実装ができたので、マイグレーションファイルを生成します。

> docker-compose exec api flask db migrate

通常は、マイグレーションしたらそのままDBへ反映すればよいのですが、Sqlalchemy UtilsでUUIDを利用しているとflask db upgrade 時にエラーとなるので、マイグレーションファイルを修正します。

sqlalchemy_utils がインポートされないので追加します。
UUIDType(length=16) となっているのを、UUIDType(binary=False) に置き換えます。

また、create_foreign_keydrop_constraint の第一パラメータがNone になっていますが、このままだと、downgradeした際にエラーになります。name みたいなので、適当に名前をつけてやります。

https://github.com/miguelgrinberg/Flask-Migrate/issues/155

正直面倒です

生成されたマイグレーションファイル(手直し箇所を抜粋)

(略)

from alembic import op
import sqlalchemy as sa
# 追加
import sqlalchemy_utils

(略)

def upgrade():
    # ### commands auto generated by Alembic - please adjust! ###
    op.create_table('parents',
#   sa.Column('id', sqlalchemy_utils.types.uuid.UUIDType(length=16), nullable=False),
    # binary=Falseに変更
    sa.Column('id', sqlalchemy_utils.types.uuid.UUIDType(binary=False), nullable=False),

(略)

#   op.add_column('hoges', sa.Column('parent_id', sqlalchemy_utils.types.uuid.UUIDType(length=16), nullable=False))
    # binary=Falseに変更
    op.add_column('hoges', sa.Column('parent_id', sqlalchemy_utils.types.uuid.UUIDType(binary=False), nullable=False))
#   op.create_foreign_key(None, 'hoges', 'parents', ['parent_id'], ['id'])
    # name指定
    op.create_foreign_key('parent_id_fk', 'hoges', 'parents', ['parent_id'], ['id'])

(略)

def downgrade():
    # ### commands auto generated by Alembic - please adjust! ###
#   op.drop_constraint(None, 'hoges', type_='foreignkey')
    # name指定
    op.drop_constraint('parent_id_fk', 'hoges', type_='foreignkey')
    op.drop_column('hoges', 'parent_id')
    op.drop_table('parents')
    # ### end Alembic commands ###

マイグレーションファイルの手直しができたらDBへ反映します。

> docker-compose exec api flask db upgrade

DBで確認するとparents テーブルやhoges テーブルにparent_id が追加されています。

mysql> show tables;
+-----------------+
| Tables_in_hoge  |
+-----------------+
| alembic_version |
| hoges           |
| parents         |
+-----------------+

mysql> SHOW COLUMNS FROM hoges;
+------------+--------------+------+-----+---------+-------+
| Field      | Type         | Null | Key | Default | Extra |
+------------+--------------+------+-----+---------+-------+
| id         | char(32)     | NO   | PRI | NULL    |       |
| name       | varchar(255) | NO   |     | NULL    |       |
| state      | varchar(255) | NO   |     | NULL    |       |
| createTime | datetime     | NO   |     | NULL    |       |
| updateTime | datetime     | NO   |     | NULL    |       |
| parent_id  | char(32)     | NO   | MUL | NULL    |       |
+------------+--------------+------+-----+---------+-------+

動作確認する

DBへ反映されたら動作確認してみます。

> curl -X POST http://localhost:5000/parents \
  -H "Content-Type:application/json" \
  -d "{\"name\":\"parents!\"}"
{
    "id": "5f2f68da-3fa2-41af-8006-87b1bf0b8305",
    "updateTime": "2018-11-02T14:50:46",
    "name": "parents!",
    "createTime": "2018-11-02T14:50:46"
}

> curl -X POST http://localhost:5000/hoges \
  -H "Content-Type:application/json" \
  -d "{\"name\":\"hoge\",\"state\":\"hoge\",\"parent_id\":\"5f2f68da-3fa2-41af-8006-87b1bf0b8305\"}"
{
    "createTime": "2018-11-02T15:01:36",
    "state": "hoge",
    "parent": {
        "createTime": "2018-11-02T14:50:46",
        "name": "parents!",
        "updateTime": "2018-11-02T14:50:46",
        "id": "5f2f68da-3fa2-41af-8006-87b1bf0b8305"
    },
    "updateTime": "2018-11-02T15:01:36",
    "name": "hoge",
    "id": "727436e1-e11d-49cd-937a-2885b82faede"
}

> curl http://localhost:5000/hoges
{
  "items": [
    {
      "createTime": "2018-11-02T15:01:36",
      "id": "727436e1-e11d-49cd-937a-2885b82faede",
      "name": "hoge",
      "parent": {
        "createTime": "2018-11-02T14:50:46",
        "id": "5f2f68da-3fa2-41af-8006-87b1bf0b8305",
        "name": "parents!",
        "updateTime": "2018-11-02T14:50:46"
      },
      "state": "hoge",
      "updateTime": "2018-11-02T15:01:36"
    }
  ]
}

はい。
うまくネストすることができました。

まとめ

思ったより実装に手間がかかる感じでした。
特にSQLAlchemyでリレーションの設定方法がいくつもあってそのすべてが今回のケースに利用というわけでなく、ハズレを引いてドハマリ。となる可能性があります(ありましたTT

ただ、Schemaでネスト定義ができるので、APIの実装でゴリゴリしなくても良いのは楽でいい感じです^^

参考

PythonのFlaskでMySQLを利用したRESTfulなAPIをDocker環境で実装する
https://cloudpack.media/43980

SQLAlchemy 1.2 Documentation – Basic Relationship Patterns
https://docs.sqlalchemy.org/en/latest/orm/basic_relationships.html

元記事はこちら

Flask-RESTfulとFlask-SQLAlchemyを利用してリソースをネストしてJSONを返す方法