標準ライブラリの範囲でpythonでweb APIのmock serverが欲しくなった場合

mock serverが欲しい

web apiのmock serverが欲しくなることがある。結構その場その場で欲しくなる機能というのが微妙に違ったりするので毎回そらで手書きする事が多い(本当は上手に抽象化できるのかもしれないけれどできていない)。今回の機能は以下の様な感じ。

  • 既に素となるresponseを返すサーバーは存在する
  • 元のサーバーが返すresponseの一部を書き換えて返したい
  • 一回の操作で複数のpathにrequestすることは無い(pathによる分岐が不要)

素となるresponseはある

例えば以下の様なresponse。実際にはもっと複雑。firstnameとlastnameを返す。

{
  "firstName": "Foo",
  "lastName": "Bar"
}

何かの加工を加えた結果を返したい

APIの変更などを適用した結果を仮に返して利用したい場合がある。ここでmock serverが欲しい。 加工した結果を返したい。例えばfullnameを追加するとか。

{
  "firstName": "Foo",
  "lastName": "Bar",
  "fullName": "Foo Bar"
}

つまり以下のような操作が欲しいということ。

# dは素となるresponse
d["fullName"] = "{d[firstName]} {d[lastName]}".format(d=d)

標準ライブラリのみのmock server

mock serverは標準ライブラリの範囲で実装したい。概ねwsgirefを使ったコードWSGI Appを作るだけ。 こういう感じのコードを使う。コードのほとんどはWSGI Appを作るところなのでだいたいコピペ(ちなみにwsgirefを使って雑なapi serverを使う例はここに書いた)。

import json
from wsgiref.simple_server import make_server


def make_App(filename, transform, usereload):
    def App(environ, start_response):
        status = '200 OK'
        headers = [('Content-type', 'Application/json; charset=utf-8')]
        start_response(status, headers)
        with open(filename) as rf:
            d = json.load(rf)

        d["fullName"] = "{d[firstName]} {d[lastName]}".format(d=d)

        return [json.dumps(d).encode("utf-8")]

    return App


if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser()
    parser.add_argument("--port", required=True, type=int)
    parser.add_argument("filename")
    args = parser.parse_args()

    httpd = make_server('', args.port, make_App(args.filename))
    print("Serving on port {}...".format(args.port))
    # Serve until process is killed
    httpd.serve_forever()

ちなみにpathからファイル名を決めるようにしようとかやってしまうと元となるapiのpathのことを考えたりする必要がでてきてだるくなる(逆にそれが不要というのは色々な種類のrequestをさばく必要がないという今回の要件に依存しているのだけれど)。

そしてできればgoだけですませたい(最近はgoを書くことも多い)気持ちがあったのだけれど(今週はgo強化週間的な気持ち)。どうも面倒だったので結局pythonで書いてしまった(型の定義したくないJSONをふわっと更新したい場合にちょっとめんどうだった。あとresponseのJSONの順序を保持したかった)。

もう少し標準ライブラリの範囲で汎用的にしてみる

ところでもう少し汎用的にして見る。標準ライブラリの範囲で。標準ライブラリに限定した理由はどこでも動かすため。

たぶん外部のライブラリを使って良いならもっと良い方法がいっぱいある。1つだけ残念な点があるとすればPYTHONPATHの指定が必要になってしまっている点(sys.pathはもっといや)。

こんな感じで使う。

$ PYTHONPATH=. python mock.py --reload --port 33333 --transform=fullname:transform data.json

firstname,lastnameから補完されてfullnameが返ってくる。

$ http -b :33333
{"firstName": "Foo", "lastName": "Bar", "fullName": "Foo Bar"}

変更箇所を別ファイルとして指定できるようにしておくと試行錯誤のときに便利。この時指定しているtransformは以下の様な感じ。

fullname.py

def transform(d):
    d["fullName"] = "{d[firstName]} {d[lastName]}".format(d=d)
    return d

ちなみにhot reload(?)のような形で動作できるようにもしたのでどのような変更が必要か定まっていない場合にも便利。サーバーを立ち上げ直さなくても変更が自動的に適用される(再読込しているだけ)。実際のところこの振る舞いを付け加えたかった。

コードはこんな感じ。

import json
from wsgiref.simple_server import make_server
from importlib import import_module, reload


def make_App(filename, transform, usereload):
    def App(environ, start_response):
        status = '200 OK'
        headers = [('Content-type', 'Application/json; charset=utf-8')]
        start_response(status, headers)
        with open(filename) as rf:
            d = json.load(rf)

        if transform is not None:
            modname, fnname = transform.rsplit(":", 1)
            m = import_module(modname)
            if usereload:
                m = reload(m)
            fn = getattr(m, fnname)

            transformed = fn(d)
            if transformed is not None:
                d = transformed

        return [json.dumps(d).encode("utf-8")]

    return App


if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser()
    parser.add_argument("--port", required=True, type=int)
    parser.add_argument("--transform", default=None)
    parser.add_argument("--reload", action="store_true")
    parser.add_argument("filename")
    args = parser.parse_args()

    httpd = make_server('', args.port, make_App(args.filename, args.transform, args.reload))
    print("Serving on port {}...".format(args.port))
    # Serve until process is killed
    httpd.serve_forever()

外部ライブラリに依存して良いなら

外部ライブラリに依存して良いなら、個人的な用途ならmagicalimportを使えばPYTHONPATHを無くせるし。直接WSGI Appを手書きするより何かパッケージを使った方が楽な気はする(flaskはimportと同時にjinja2を読み込んでしまうのでちょっとだけイラッとする(ちなみにjinja2のimportは2.10で早くなったことを教えてもらったりした))。あと毎回moduleをreloadは気持ち悪いなーとか。