django-pistonとbpmappersでWebAPIを作る

hfunaiの記事に便乗。
http://blog.monospace.jp/2010/10/31/django_piston_intro/
django-pistonを使うとDjangoで簡単にWebAPIを作れます。
jespern / django-piston / wiki / Home — Bitbucket
fields/excludeにフィールド名を指定すると、必要なフィールドのみを出力したりできます。
ただ、APIで提供するデータの構造とモデルの構造が一対一の対応にならない場合、辞書を頑張って生成するか、ハンドラのフックポイントを実装するか、Emitterクラスをゴリゴリ書くなど、ちょっと面倒です。
そういう場合に、bpmappersと組み合わせるといいかも、という話です。
今回はGETでデータを取得(参照)する場合のみを想定してます。保存する場合はFormで頑張ろう・・・。

bpmappersについて

bpmappersはモデルを辞書へのデータのマッピングを支援するモジュールです。
bpmappers ドキュメント - サイトは移転しました
BeProudでも結構使ってます。

ふつうにpistonを使ってみる

まずは普通にpistonを使った場合。

myapp/models.py
from django.db import models

class Person(models.Model):
    name = models.CharField(max_length=20)

class Book(models.Model):
    title = models.CharField(max_length=20)
    author = models.ForeignKey(Person)

こんなモデルを書いて、適当にfixtureでデータを入れておきます。

myapp/fixtures/test.json
[
  {
    "pk": 1,
    "model": "myapp.person",
    "fields": {
      "name": "monjudoh"
    }
  },
  {
    "pk": 2,
    "model": "myapp.person",
    "fields": {
      "name": "wozozo"
    }
  },
  {
    "pk": 1,
    "model": "myapp.book",
    "fields": {
      "author": 2,
      "title": "python book"
    }
  },
  {
    "pk": 2,
    "model": "myapp.book",
    "fields": {
      "author": 1,
      "title": "js book"
    }
  }
]
api/urls.py
from django.conf.urls.defaults import *
from piston.resource import Resource
from api.handlers import BookHandler

book_handler = Resource(BookHandler)

urlpatterns = patterns('',
   url(r'^book/(?P<object_id>[^/]+)/', book_handler),
)
api/handlers.py
from piston.handler import BaseHandler
from myapp.models import Person, Book

class BookHandler(BaseHandler):
    allowed_methods = ('GET',)
    fields = ('title', ('author', ('id', 'name')))
    model = Book

    def read(self, request, object_id):
        return Book.objects.get(pk=object_id)

厳密にはエラー処理なども必要ですが、省略してます。authorの下に "_state" というのが出てしまうのでfieldsを指定しています。
ディレクトリ構造はこんな感じ

$ tree myproject
myproject
|-- __init__.py
|-- api
|   |-- __init__.py
|   |-- handlers.py
|   |-- models.py
|   |-- urls.py
|   `-- views.py
|-- manage.py
|-- myapp
|   |-- __init__.py
|   |-- fixtures
|   |   `-- test.json
|   |-- models.py
|   `-- views.py
|-- settings.py
|-- test.db
`-- urls.py

/api/book/1/ の出力は次のようになります。

{
    "author": {
        "name": "wozozo", 
        "id": 2
    }, 
    "title": "python book"
}

モデルのプロパティがそのままマッピングされて出力されています。さてここまではいいのですが...
最終的にほしかった出力結果はこうではなく、キー名が変更された以下のような形でした。

{
    "book_title": "python book", 
    "author_info": {
        "author_name": "wozozo",
        "author_id": 2
    }
}

さて対応してみましょう。先ほどのBookHandlerを書き換えました。

api/handlers.py
from piston.handler import BaseHandler
from myapp.models import Person, Book

class BookHandler(BaseHandler):
    allowed_methods = ('GET',)

    def read(self, request, object_id):
        obj = Book.objects.get(pk=object_id)
        return {
            'book_title': obj.title,
            'author_info': {
                'author_id': obj.author.id,
                'author_name': obj.author.name,
            },
        }

辞書でマッピングすることで解決できました。
実際に扱うシステムでは、もっと情報量が多く、似た様なAPIがあったりすることもあります。そうなると、このマッピング部分のメンテナンスやコードの再利用のコストが増えてきます。
そこでbpmappersを使います。

bpmappersを使ってマッピングしてみる

api/mappers.pyを新たに作成し、api/handlers.pyを書き換えました。

api/mappers.py
from bpmappers import Mapper, RawField, DelegateField

class AuthorMapper(Mapper):
    author_id = RawField('id')
    author_name = RawField('name')

class BookMapper(Mapper):
    book_title = RawField('title')
    author_info = DelegateField(AuthorMapper, 'author')
api/handlers.py
from piston.handler import BaseHandler
from myapp.models import Person, Book
from api.mappers import BookMapper

class BookHandler(BaseHandler):
    allowed_methods = ('GET',)

    def read(self, request, object_id):
        obj = Book.objects.get(pk=object_id)
        return BookMapper(obj).as_dict()

これでデータのマッピングのみをmappers.pyに切り出せました。
AuthorMapperやBookMapperを他の部分で再利用することも簡単です。
複雑な辞書を何種類も扱っていて、「似てるけどちょっとだけ違う構造にしたい」といった場合にはbpmappersは大活躍してます。

おわりに

今回のはDjangoの話でしたが、bpmappersはDjangoじゃなくても使えるので、例えばJSONRPC+AppEngineの組み合わせで使ったりするのも面白そうだなーとか思ってます。それはまた別の機会に。