🏸

スキーマ駆動開発×React Queryでの快適React開発ライフ

2022/12/29に公開

はじめに

スキーマ駆動開発、していますか?
まずは(openAPIで)API仕様を定義し、バックエンドとフロントエンドがいずれもAPI仕様を前提として開発を進めていく例のあれです。openAPIエコシステムが結構充実しているので、バックもフロントも色々恩恵に預かりやすい今日このごろです。

話は変わって、React Query便利ですよね。鬼のProvider地獄や、Reduxで苦しんでいた時代が懐かしいです。
とは言っても、そこそこの規模のシステムになるとそこそこの数のAPIを叩くので、そこそこの量のAPIコールを行うクライアントコードの作成が必要になります。

api.ts
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コール関数を渡してあげる必要があります。

apiWrapper.ts
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の内容を前提としています。

手順

  1. インストール

    $ npm i -D @openapi-codegen/{cli,typescript}
    $ npx openapi-codegen init
    
  2. 設定ファイルを適宜修正

    openapi-codegen.config.ts
    import { 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
          })
        }
      }
    })
    
    
  3. 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
    
  4. 自動生成!

    // 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を覗いてみます。

projectNameSchema.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です。

projectNameComponent.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して使うことになります。
使い方の例はこちら。

Test.tsx
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

  1. ESLint対応
    設定ファイルにしれっと記載しましたが、一部のESLintルールに猛烈に違反するコードを生成してきます。
    自動生成分なので良いだろうと思い、eslint適用を無効化するプレフィックスを自動で付加させるようにしています。

  2. 複数環境対応
    自動生成コードでのエンドポイントは、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