スキーマ駆動開発×React Queryでの快適React開発ライフ
はじめに
スキーマ駆動開発、していますか?
まずは(openAPIで)API仕様を定義し、バックエンドとフロントエンドがいずれもAPI仕様を前提として開発を進めていく例のあれです。openAPIエコシステムが結構充実しているので、バックもフロントも色々恩恵に預かりやすい今日このごろです。
話は変わって、React Query便利ですよね。鬼のProvider地獄や、Reduxで苦しんでいた時代が懐かしいです。
とは言っても、そこそこの規模のシステムになるとそこそこの数のAPIを叩くので、そこそこの量のAPIコールを行うクライアントコードの作成が必要になります。
export const getUser = async (props: GetUserPropsType): GetUserReturnType => {
const user = await axios.get('/path').catch((error) => {
throw new Error()
})
return user
}
..... (膨大な数のAPIコール関数群)
そして、React Queryの恩恵に預かるためには、useQueryにこれらAPIコール関数を渡してあげる必要があります。
export const {data, isError, isLoading, refetch} = useQuery('/path', getUser)
ここまで完成すれば便利なんですが、正直最初の整備はかなり気乗りしない作業です。
加えて、とくに開発初期ではAPI仕様の変更が頻発するため、型情報や引数などの更新も負担です。
フロント担当者 「このAPI、引数の型numberじゃなかったっけ..??」
バック担当者 「ごめん!昨日booleanに更新されたよ!あと返り値にname: stringも追加された!定義書は更新されてるからそっちみて!」
フロント担当者 「えっと引数型をnumber -> booleanにして、あとname: stringを返り値に追加..っと」
まあこんな修正がそこそこ重なるうちに、しょうもないミスで動かない〜と騒ぎ始めるわけです。(心当たりしかない)
openAPIからクライアントコードの自動生成
そんなみなさまにおすすめなのが、openAPIからクライアントコードを自動生成するツール群です。
一番有名所はopenapi-generatorでしょうか。各種言語に対応しており、型情報生成などもちろんやってくれます。
筆者もお世話になってたのですが、こいつでもまだ手の届かないところがあります。それは、react queryには非対応という点です。
上記のaxiosで叩くところのコードまでは自動生成してくれますが、useQueryで包んだところまで出してもらえるともっと嬉しいなあという欲深さを徐々に感じるようになってきました。
openapi-codegen
そんなある日、GitHubをうろうろしていたところ見つけました。
なんとopenAPIからReactQuery対応のクライアントコードを自動生成してくれるようです!素敵!
以下、v6.0.0の内容を前提としています。
手順
-
インストール
$ npm i -D @openapi-codegen/{cli,typescript} $ npx openapi-codegen init
-
設定ファイルを適宜修正
openapi-codegen.config.tsimport { generateSchemaTypes, generateReactQueryComponents } from '@openapi-codegen/typescript' import { defineConfig } from '@openapi-codegen/cli' export default defineConfig({ // ここのprojectNameを自動生成時にkeyとして用いる projectName: { from: { // openAPIのパス relativePath: './path/to/openapi.yaml', source: 'file' }, // 出力先のパス outputDir: './src/api', to: async (context) => { // ESLintルール違反コードを大量に生成するため、eslint-disableを自動で付加 // 参照: https://github.com/fabien0102/openapi-codegen/issues/96#issuecomment-1243867098 const writeFile = context.writeFile context.writeFile = (file, data) => writeFile(file, `/* eslint-disable */\n\n${data}`) const filenamePrefix = 'projectName' const { schemasFiles } = await generateSchemaTypes(context, { filenamePrefix }) await generateReactQueryComponents(context, { filenamePrefix, schemasFiles }) } } })
-
openAPIを作る
openapi: 3.0.0 info: version: 1.0.0 title: TEST API servers: - url: https://localhost:8003 description: 開発用mocサーバ paths: /users: get: summary: ユーザ情報 一括取得用API tags: - ユーザ operationId: get-users # 必須!! description: hogehoge parameters: - name: is_enabled in: query description: 有効フラグ required: false schema: type: boolean responses: 200: description: 成功 content: application/json: schema: type: array items: $ref: '#/components/schemas/user' example: - id: 1 name: Sato Taro age: 23 is_enable: true post: summary: ユーザ情報 登録用API tags: - ユーザ operationId: post-users description: ユーザ情報を登録します requestBody: description: 登録用データ required: true content: application/json: schema: type: array items: $ref: '#/components/schemas/registerUser' responses: 200: description: 成功 content: application/json: schema: $ref: '#/components/schemas/Success' components: schemas: user: type: object required: [id, name, age, is_enabled] properties: id: type: integer description: ユーザID name: type: string description: ユーザ名 age: type: integer description: 年齢 nullable: true is_enabled: type: boolean description: 有効フラグ registerUser: type: object required: [name, age] properties: name: type: string description: ユーザ名 age: type: integer description: 年齢 nullable: true is_enabled: type: boolean description: 有効フラグ Success: type: object properties: statusCode: type: integer format: int32 example: 200 Error: type: object properties: statusCode: type: integer format: int32 example: 400 Unauthorized: type: object properties: statusCode: type: integer format: int32 example: 401 securitySchemes: Bearer: type: http scheme: bearer description: ID token for API
-
自動生成!
// projectNameは設定ファイルで記載したもの // package.jsonでコマンドを定義しておきましょう (ex. yarn generate-api) $ npx openapi-codegen gen projectName
使い方
先程のコマンドの結果、./src/apiにprojectNameComponent.ts, projectNameContext.ts, projectNameFetcher.ts, projectNameSchema.tsの4ファイルが生成されます。
主に使うのは型情報が記載されたSchema.tsと、reactQueryのwrapper hookが記載されたComponent.tsです。
まずSchema.tsを覗いてみます。
export type User = {
/**
* ユーザID
*/
id: number
/**
* ユーザ名
*/
name: string
/**
* 年齢
*/
age: number | null
/**
* 有効フラグ
*/
is_enabled: boolean
}
export type RegisterOrganization = {
/**
* ユーザ名
*/
name: string
/**
* 年齢
*/
age: number | null
/**
* 有効フラグ
*/
is_enabled?: boolean
}
openAPIでの定義通りに型情報が定義されています。特段目新しいものも無いですが、大事ですね。
openAPI上でのrequiredによってundefinedを、nullableオプションでnullを含めるか定義することができています。
本命はこちら、Component.tsです。
export type GetUsersQueryParams = {
/**
* 有効フラグ。trueの場合is_enabled=trueとなっているマスタデータのみが、falseの場合全てのマスタデータが返される。
*/
is_enabled?: boolean
}
...
/**
* hogehoge
*/
export const useGetUsers = <TData = GetUsersResponse>(
variables: GetUsersVariables,
options?: Omit<reactQuery.UseQueryOptions<GetUsersResponse, GetUsersError, TData>, 'queryKey' | 'queryFn'>
) => {
const { fetcherOptions, queryOptions, queryKeyFn } = useRemsContext(options)
return reactQuery.useQuery<GetUsersResponse, GetUsersError, TData>(
queryKeyFn({ path: '/userss', operationId: 'getUsers', variables }),
({ signal }) => fetchGetUsers({ ...fetcherOptions, ...variables }, signal),
{
...options,
...queryOptions
}
)
}
...
/**
* ユーザ情報を登録します
*/
export const usePostUsers = (
options?: Omit<reactQuery.UseMutationOptions<Schemas.Success, PostUsersError, PostUsersVariables>, 'mutationFn'>
) => {
const { fetcherOptions } = useRemsContext()
return reactQuery.useMutation<Schemas.Success, PostUsersError, PostUsersVariables>(
(variables: PostUsersVariables) => fetchPostUsers({ ...fetcherOptions, ...variables }),
options
)
}
実際のReact ComponentではuseGetUsers()とusePostUsers()の2関数をimportして使うことになります。
使い方の例はこちら。
const { data, isLoading, refetch } = useGetUsers(
{
// ヘッダ
headers: {...},
// クエリパラメータ
queryParam: {
is_enabled: false
}
},
{
onSuccess: () => {
console.log('success!')
}
}
)
// dataを使って描画したり、isLoadingやrefetchを活用したりする
const { mutate } = usePostUsers()
...
// mutateでonSubmit時などにPOST処理を行う
mutate({
body: {
name: 'Sato Jiro',
age: null
headers: {...}
}
})
型情報はopenAPI通りに定義されているので、TypeScriptの恩恵を十二分に享受できます。
また、頻発するAPI仕様の修正に対しても、openAPIさえ修正してくれれば自動生成コマンドの実行だけでフロント側の対応はほぼ完了します。
もちろん、refetch, isError, isLoading, onSuccess, onError, etc.. といったreact queryの便利な変数達も使えます。
Tips
-
ESLint対応
設定ファイルにしれっと記載しましたが、一部のESLintルールに猛烈に違反するコードを生成してきます。
自動生成分なので良いだろうと思い、eslint適用を無効化するプレフィックスを自動で付加させるようにしています。 -
複数環境対応
自動生成コードでのエンドポイントは、openAPI上に記載されたserversセクションに一致していますservers: - url: https://api-endpoint
openAPI上では複数のserversを記述することができますが、現状のopenapi-codegenでは最上位に書かれたURLをエンドポイントとして自動生成を行います。
したがって、開発、ステージング、本番とエンドポイントを分けたい場合は、直接yamlを書き換えた上でクライアントコードを再生成するステップをCDに組み込む必要があります。以下にgithub actionsで実行する例を示します。yqを使って、環境変数に設定したURLで書き換えた上で、クライアントコードの再生成 & デプロイを行っています。
name: deploy jobs: build-and-deploy-development: steps: # checkout - name: Checkout source code uses: actions/checkout@v2 # packageのインストール - name: install dependencies run: | yarn --cwd ./backend/cdk --frozen-lockfile yarn --cwd ./frontend --frozen-lockfile # openAPI上でのAPIエンドポイントを更新 - name: Update openAPI id: update_openapi uses: mikefarah/yq@master with: cmd: yq e -i '.servers[0].url|="'$API_ENDPOINT_URL'"' ./backend/api/openapi.yaml env: API_ENDPOINT_URL: ${{ secrets.API_ENDPOINT_URL_DEVELOP }} # クライアントコードの再生成 - name: Regenerate API client code run: | rm ./frontend/src/api/* yarn --cwd ./frontend generate-api # deploy ...
最後に
正直ぽっと出の怪しいライブラリ感はありますが、一応そこそこ有名だったrestful-reactの後継のようなので、とりあえず大丈夫だろうと思っています。
(あと、おんなじようなことできるライブラリを、半年前くらいにいくつか検証したんですが、まともに動いたのがこいつくらいだった記憶があります。今はもうちょいマシかも..?)
フロント向けモックAPIサーバを提供するprism, 仕様書HTMLを生成してくれるredoc, などなど、openAPI周りのエコシステムには非常に強力なツールがたくさんあります。
そもそもopenAPIの整備が面倒という点はありますが、そこを乗り越えたときに享受できるメリットはフロントバック問わずとても大きいものだと思っています。
日頃お世話になっているので、もっと流行ってほしいの願いを込めた紹介記事でした。
Discussion