Django のリクエストテストで API の戻り値の型を吐き出す
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という 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_TS
が true
であることと、 Content-Type
が application/json
であればファイルが出力されます。CI では OUTPUT_TYPE_TS
が true
にはしないのでテストのたびにファイルが出力されるのを防いでいます。
ここで出力されるファイル名は 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 になってしまうことや複数回呼び出されたら上書きされてしまう問題等まだありますが、一旦これで目的は達せられました。もう少し改良してうまい具合にいったらパッケージの公開とかしてみたいですね。
Discussion