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