# Django のリクエストテストで API の戻り値の型を吐き出す

Django で API を作るとき Django Rest Framework(以下 DRF)を使うと Swagger を使ってドキュメントを吐き出したり、そのドキュメントを利用して @openapitools/openapi-generator-cli を使い SDK を吐き出したりと何かとサーバサイドとフロントエンドでうまい具合に連携できて便利です。特に TypeScript を使用する場合は型による恩恵が受けられるのでとても嬉しいと思います。

しかし Django のアプリケーションで DRF を使っているかというとそうでない場合もあり、その場合はサーバサイドとフロントエンドの間が断絶し、API の返り値などはあたたかみのある手作業で型を作ったり作らなかったりすることが多いと思います。自分も仕事で開発しているアプリケーションは DRF を使用していないので、毎回 API の返り値は何だったかとバックエンドのコードを覗いたり、自分で手書きで TypeScript の type を書いたりしていました。

なんとも微妙だなと思っていたところ Rails ではいい感じの gem が存在しました。rspec-openapi (opens new window)という RSpec の request spec から OpenAPI の Schema を吐き出す gem です。テストを書くことで安心感を得た上でフロントエンド開発に型をもたらすことで更に安心できるという一挙両得なソリューションです。

そこで rspec-openapi のようにテストから入るなら Django Rest Framework を使わずに API の型を公開できるので、自分が抱えていた問題をうまく解決できると考えました。

今回は Django の API のテストから TypeScript の type を吐き出す仕組みを作ってみました。Open API の Schema を吐き出すことも考えたのですが、まずはミニマムで実装してみました。

# リクエストテストのための Client を継承した GenTypeClient を作成する

Django の API のテストはざっと次のように書くと思います。

from django.test import Client, TestCase

class SampleAPIVTest(TestCase):

    def test_tests_get(self):
        response = Client().get('/api/tests',
                                   content_type='application/json')
        self.assertEqual(response.status_code, 200)

Client().getで API を叩いて response を受け取っています。この response は status_code や body を持っています。通常のテストだと body の中身を取り出して正しい値が入っているかテストを行うのですが、今回はここで取り出した中身の JSON を解析し type を吐き出すことにしました。

テストコードの中で type を出力するコードを書くのはテストコードとしては目的外のことをすることになるので微妙です。なので今回は Client のほうに細工をします。 Client をラップする GenTypeClient を作ります。

class GenTypeClient(Client):
    def __init__(self):
        super(GenTypeClient, self).__init__()
        # 各HTTPリクエストを行うメソッドをoutput_typeデコレータで包んで上書きする
        self.get = self.output_type(self.get)
        self.post = self.output_type(self.post)
        self.put = self.output_type(self.put)
        self.patch = self.output_type(self.patch)
        self.delete = self.output_type(self.delete)

    def output_type(self, func):
        def wrapper(*args, **kwargs):
            res = func(*args, **kwargs)

            # 環境変数OUTPUT_TS_TYPEが有効でないときはそのまま返す
            if os.environ.get('OUTPUT_TS_TYPE') != 'true':
                return res

            # JSONを返すAPIでないときはそのまま返す
            if res['Content-Type'] != 'application/json':
                return res
            method = res.request['REQUEST_METHOD']

            words = list(
                chain.from_iterable(
                    map(
                        lambda x: 'ID' if x.isdecimal() else x.split('_'),
                        res.request['PATH_INFO'].split('/')
                    )
                )
            )

            file_name = method + ''.join([word.capitalize() for word in words])
            # typesディレクトリ以下に型ファイルが吐き出される
            file = 'types/{}.ts'.format(file_name)
            fileobj = open(file, 'w', encoding='utf_8')
            fileobj.write('/* eslint-disable */\n')
            fileobj.write('// THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.\n')
            fileobj.write('export type {0} = {{\n'.format(file_name))
            # 返り値のJSONを探索するメソッド次の章で解説
            explore(res.json(), fileobj)
            fileobj.write('}\n')
            fileobj.close()

            return res
        return wrapper

単純に Client をラップし、条件が整えばファイルを出力するように作りました。環境変数の OUTPUT_TYPE_TStrue であることと、 Content-Typeapplication/json であればファイルが出力されます。CI では OUTPUT_TYPE_TStrue にはしないのでテストのたびにファイルが出力されるのを防いでいます。

ここで出力されるファイル名は GET リクエストで /api/tests/:id だとすると GETApiTestsID.ts になります。1API ごとに 1 つ ts ファイルが出力されます。同じ API を複数回テストするコードだと毎回上書きされてしまうところが微妙ですが、とりあえず最低限の出力が可能です。

このコードではファイルの作成と次に解説する explore に書き込み途中のファイルを渡すところまで行います。 explore で型情報を書き込み終えたら close して終えます。

# JSON を走査し型を書き込む explore を作る

実際に JSON を操作し型を書き込む explore を作ります。

def explore(edge, fileobj, num=1):
    for k, v in edge.items():
        if type(v) is dict:
            _add_tab(fileobj, num)
            fileobj.write('{0}: {{\n'.format(k))
            explore(v, fileobj, num + 1)
            _add_tab(fileobj, num)
            fileobj.write('};\n')
        elif type(v) is list:
            _add_tab(fileobj, num)
            # listは全部anyになってしまう問題がある
            fileobj.write('{0}: Array<any>;\n'.format(k))
        else:
            _add_tab(fileobj, num)
            type_st = _return_type_st(v)
            fileobj.write('{}: {};\n'.format(k, type_st))


def _return_type_st(param):
    '''
    渡された値の型を返す
    '''
    typeObj = type(param)

    if typeObj is str:
        return "string"

    if typeObj is bool:
        return "boolean"

    if typeObj is int:
        return "number"

    return "any"


def _add_tab(fileobj, num):
    '''
    指定された数のtabを書き込む
    '''
    for _ in range(num):
        fileobj.write('  ')

explore 関数では渡された JSON の要素を一つ一つ確認し型を書き込んでいく処理が書かれています。再起させて一つずつ見ていくのですが、唯一 list だけがうまい方法が見つけられず Array<any> でお茶を濁しています。

もし API で、

{
    "samples": [
        1,
        "hoge"
        {
            "fuga": "fuga"
        }
    ]
}

という返し方をされたときにどう type を組み立てるか、まだきれいな書き方が見えてない感じです。時間をかければ思いつきそうですが、一旦 any を許容しています。

explore で要素ごとに型をつけていき、例えば次のような JSON を食わせると。

// /api/test/:id
{
  "hoge": "hoge",
  "fuga": 0,
  "foo": {
    "bar": "bar"
  },
  "array": [1, 2, 3]
}

次のようなファイルが吐き出されます。

/* eslint-disable */
// THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
export type GETApiTestID = {
  hoge: string;
  fuga: number;
  foo: {
    bar: string;
  };
  array: Array<any>;
};

あとはこれを API を呼ぶ部分で使用し活用します。

# まとめ

きっと多分もっとスマートな方法はあるし、テストから JSON をゴニョゴニョしないで API の値を返すコードから生成するほうがひょっとしたら良いかも知れないけど、とりあえず API のリクエストテストから返り値の型を生成できました。

配列が any になってしまうことや複数回呼び出されたら上書きされてしまう問題等まだありますが、一旦これで目的は達せられました。もう少し改良してうまい具合にいったらパッケージの公開とかしてみたいですね。