🏭

openapi-generator を使ってフロントエンドの api を自動生成コードに置き換える

13 min read

この記事は Calendar for Legalscape アドベントカレンダー 2021 の 17日目の記事で、前後編のうちの前編です。後編は「 openapi-generator フレンドリーな OpenAPI ドキュメントを書く 」というタイトルで 12/22 に公開されました

前提条件

Vue2 + Nuxt.js + nuxt-property-decorator の構成に TypeScript は導入済み。 API クライアントには axios を使用して 14カテゴリでトータル 50弱のエンドポイントが存在しています

サーバサイドでは express-openapi-validator などを使って実装と定義のチェックを行うなど、 openapi.yaml の定義を活用した実装を行なっています。しかし、フロントエンドでは定義を目視で確認する位にしか活用できておらず、サーバサイドで定義した型と同じものを再定義していて、新しいエンドポイントを追加するたびに悲しい気持ちになっていました。ちょうど機能実装がひと段落ついたタイミングがあったので openapi.yaml を E2E で活用しようぜ!という強い気持ちで置き換えに着手しました

生成する

openapi-generator の Docker イメージ を利用して API 関連のコード生成してみましょう。何も指定しないとすべてがひとつのファイルとして出力されてしまうので、 withSeparateModelsAndApi=true などのオプションをを指定して、 apimodel がフォルダに生成されるようにします

docker run \
  --rm \
  -v `pwd`/gen/openapi.yaml:/docs/openapi.yaml \
  -v `pwd`/gen:/out \
  openapitools/openapi-generator-cli \
  generate \
  -g typescript-axios \
  -i /docs/openapi.yaml \
  -o /out \
  --api-package api \
  --model-package model \
  --generate-alias-as-model \
  --additional-properties withInterfaces=true \
  --additional-properties withSeparateModelsAndApi=true

不要なファイルは .openapi-generator-ignore で ignore しておきます

gen/.openapi-generator-ignore
# .openapi-generator-ignore
git_push.sh
.gitignore
.npmignore

同じコードベースに openapi.yaml が存在していればいいですが、そうじゃない場合はどうにかして持ってこないと行けません。今回は Makefile を利用してビルドのためのコマンドを集約しました。 openapi.yaml は別レポジトリに存在しているので curl コマンドで取得するようにします

Makefile
BRANCH := main

fetch_api: ## ブランチを指定して openapi の定義ファイルをダウンロードする
  @curl \
    -s \
    --fail \
    -o $$(pwd)/frontend/gen/openapi.yaml \
    -H "Authorization: Bearer ${GITHUB_OAUTH_TOKEN}" \
    -H "Accept: application/vnd.github.v3.raw" \
    https://api.github.com/repos/org/repo/contents/api/openapi.yaml?ref=${BRANCH}

oas_gen_ts_axios: ## openapi.yamlからAPIクライアントを生成します
  docker run --rm -v $$(pwd)/frontend/gen/openapi.yaml:/docs/openapi.yaml -v $$(pwd)/frontend/gen:/out \
    openapitools/openapi-generator-cli \
    generate \
    -g typescript-axios \
    -i /docs/openapi.yaml \
    -o /out \
    --api-package api \
    --model-package model \
    --generate-alias-as-model \
    --additional-properties withInterfaces=true \
    --additional-properties withSeparateModelsAndApi=true

fetch_and_generate: fetch_api oas_gen_ts_axios ## ブランチを指定して定義ファイルを取得しAPIクライアントを生成します

.PHONY: oas_gen_ts_axios fetch_and_generate

生成する処理はここまでで完了です。上記の make コマンドを CI に追加して、 CI 上でも自動生成したコードを利用できるようにしましょう

置き換える

Legalscape のリクエスト系はベーシックにレポジトリ層の注入を Nuxt のプラグインで実装して、そこで api と repository のインスタンスを生成しています。といってもすべてがそうなっているわけではなく、コンポーネントから直接 axios.get() などを実行していたり、 api を直接レポジトリとして注入しているなどのものもありました

plugins/repositories.ts
import { DefaultApi } from '@/api/DefaultApi';

export default <Plugin>(({ app }, inject) => {
  const defaulyApi = new DefaultApi(app.$axios);
  const defaultRepository = new DefaultRepository(defaulyApi);

  const repositories: Repositories = {
    default: defaultRepository,
    user: new UserApi(app.$axios),
  };
  inject('repositories', repositories);
})
api/defaultApi.ts
import { NuxtAxiosInstance } from '@nuxtjs/axios';
import { SomeResponse } from '@/types/responses'

export class DefaultApi {
  constructor(private axios: NuxtAxiosInstance) {}

  async getSome(): Promise<SomeResponse> {
    const resp = await this.axios.get<SomeResponse>(`/some`);
    return resp.data;
  }
}
api/defaultRepository.ts
import { DefaultApi } from '@/api/DefaultApi';
import { SomeResponse } from '@/types/responses'

export class DefaultRepository {
  constructor(private api: DefaultApi) {}

  async getSome(): Promise<SomeResponse> {
    const resp = await this.api.getSome();
    return resp;
  }
}

今回、すべての API エンドポイントが自動で生成されるようになったので、同時にすべてのレポジトリ層の実装を統一しました。コンポーネントから直接呼んでいる箇所の置き換えは少し手間がかかりますが、今後の事を考えると一箇所に集約した方がメンテナンスはしやすいと思います

レポジトリを置き換える

ここはそこまで大変ではなく以下の 2ステップで完了です

  1. レポジトリのコンストラクタ or 生成メソッドの引数を axiosInstance から自動生成したクラスに変更する
  2. レポジトリの中で axiosInstance.get() などを呼んでいた箇所を自動生成したものに置き換える

レスポンスの型定義が openapi.yaml で定義したものと齟齬がなければエラーは発生しないはずです。もしエラーが発生したら、それは定義と実装に不整合があるという事なので、改善のチャンスと思って次のセクションの要領で対応しましょう

api/defaultRepository.ts
import { DefaultApi } from '@/gen/api/default-api';
import { SomeResponse } from '@/types'

export class DefaultRepository {
  constructor(private api: DefaultApi) {}

  async getSome(): Promise<SomeResponse> {
    const resp = await this.api.getSome();
    return resp;
  }
}

この際、 encodeURIComponent を通している箇所があったらそれは不要になるのでご注意ください

型定義を置き換える

問題なければ、レスポンスやモデルの型などバックエンドの型定義をフロントで再定義しているものを削除、自動生成したものに置き換えればそこで作業は完了です

api/defaultRepository.ts
import { DefaultApi } from '@/gen/api/default-api';
import { SomeResponse } from '@/gen/model'

export class DefaultRepository {
  constructor(private api: DefaultApi) {}

  async getSome(): Promise<SomeResponse> {
    const resp = await this.api.getSome();
    return resp;
  }
}

型定義の不整合を直す

置き換えを行ったタイミングで型のエラーがでた場合、理由は以下のどちらかです

  • openapi.yaml が間違っている
  • フロントエンドの型定義が間違っていた

例えば required が揃っていなかったり、存在するはずのプロパティがなかったり、逆に余分に存在したり、などです。当然、それぞれ理由は違うはずのでそれを突き止めててひとつずつ直していきます。「現状動いているシステムなのであれば基本的には定義を変えても問題ない」はずです。サーバサイドのメンバーとコミュニケーションしながら修正していきましょう

前述の通り、型定義や openapi.yaml を変更したとしてもシステムが壊れることはありません。恐れずに一気に書き換えていきましょう

型のチェックを追加する

フロントエンドの型定義が間違っていて、オプショナルなはずのプロパティがそうなっていなかった場合、利用箇所で型のチェックを追加する必要があります

// フロントエンドの定義では book.author は required
const getBookInfo = (book: Book): string => {
  return `${book.title} / ${book.author}`
}
// openapi.yaml の定義では book.author はオプショナル
const getBookInfo = (book: Book): string => {
  if (book.author) {
    return `${book.title} / ${book.author}`
  } else {
    return `${book.title} / 著者不明`
  }
}

基本的にはこれによって壊れることはないはずですが、若干のロジックの変更が発生するのでテストは入念にしましょう。また、追加でロジックを実装しないといけない場合もあります。「これは想定できていなかった潜在的な不具合」なので、品質を高めるチャンス!と捉えて前向きに対応していきます

また、一時的に unknown を経由した型キャストで一時凌ぎをする、という方法もあります。将来のことを考えるときちんと直し切ることをお勧めしますが必要に応じて活用しましょう

import { HogeHugaApi } from '@/gen/api'

// 元々あったフロントの型定義
type ResponseType = { hoge: string, huga: string }

export class hogeHugaRepository {
  constructor(pribate api: HogeHugaApi)

  getHogeHuga(): Promise<ResponseType> {
    const { data } = this.api.getHogeHuga() // getHogeHuga の返り値は { hoge?: string, huga?: string }
    // FIXME: ResponseType を使わずに自動生成の型を使うようにする
    return (data as unknown) as ResponseType
  }
}

また、テンプレートの型チェックに関しては環境によって効いたり効かなかったりすると思います。ここに関しては目視で確認するしかありません。なんだかんだ言って動作確認は必要なので、きちんと影響範囲を確認して確認していきましょう

定数を使ったユニオン型の実装について

type プロパティを使って型を切り替えるというのは良くあるテクニックだと思いますが、 openapi.yaml では定数を利用できないので、これを実装するのがちょっと難しいです

type UserBase = {
  name: string
}

type NormalUser = UserBase & {
  type: 'member'
}

type AdminUser = UserBase & {
  type: 'admin'
  division: string
}

type OwnerUser = UserBase & {
  type: 'owner'
  billing: 'monthly' | 'yearly'
}

type UserResponse = NormalUser | AdminUser | OwnerUser

openapi.yaml 側でひとつの型定義を使いまわしていると以下のような残念なコードになってしまいます

type UserResponse = {
  type: 'member' | 'admin' | 'owner'
  division?: string
  billing?: 'yearly' | 'annual'
}

詳しくは 22日のアドベントカレンダーで予定している「openapi-generator フレンドリーな OpenAPI ドキュメントを書く」で説明しますが、具体的には enum を利用します

openapi.yaml
components:
  responses:
    UserResponse:
      description: OK
      content:
        application/json:
          schema:
            type: array
            items:
              $ref: '#/components/schemas/User'
  schemas:
    User:
      oneOf:
        - $ref: '#/components/schemas/NormalUser'
        - $ref: '#/components/schemas/AdminUser'
        - $ref: '#/components/schemas/OwnerUser'
    BaseUser:
      type: object
      properties:
        name:
          type: string
      required:
        - name
    NormalUser:
      allOf:
        - $ref: '#/components/schemas/BaseUser'
        - type: object
          properties:
            type:
              type: string
              enum:
                - 'member'
          required:
            - type
      
...

このように定義した上で、フロントエンド側で NormalUserEnum で型アサーションすると適切に型をハンドリングできます

const SomeMethod(response: UserResponse) {
  if (response.type === NormalUserEnum.Normal) {
    // ここのなかでは response は `NormalUser` になる
  }
}

フラグを使ったユニオン型の実装について

若干アンチパターン感はありますが、フラグを利用して追加のプロパティが増えるようなユニオン型もあるかと思います

type NormalUser = {
  isSpecial: false
  hoge: string
  muge: string
}

type SpecialUser = Omit<NormalUser, 'isSpecial'> & {
  isSpecial: true
  superHoge: string
  superMuga: string
}

type UserResponse = NormalUser | SpecialUser

こちらに関しては安全な実装はできません。なぜかというと openapi.yaml には定数がなく、また openapi.yaml の Enum には boolean 値は使えないためです。フラグを利用しないようにサーバ側の実装を変えてもらえると一番いいのですが、まぁ現実的ではないため、ここでは型アサーション関数を作って対応するようにします

const isSpecialUser = (response: UserResponse): response is SpecialUser => response.isSpecial === true;

const SomeMethod(response: UserResponse) {
  if (isSpecialUser(response)) {
    // ここのなかでは response は `SpecialUser` になる
  } else {
    // ここのなかでは response は `NormalUser` になる
  }
}

ユニオン型を Enum に置き換える

フロントではユニオン型で定義していて、 openapi.yaml では stringinteger で指定している場合、情報が劣化して型エラーが発生します。その場合は enum を設定する事で中に含まれる値を限定する事ができます

switch 文の default が不要になったりするので置き換えていきましょう。 Legalscape の環境では同値の別の Enum の比較が型エラーにならないですが、設定によっては型エラーになるようなので、環境によってより安全なコードベースを維持できると思います

openapi.yaml
components:
  schemas:
    DocumentTypeEnum:
      description: 文献の種別
      enum:
        - book
        - law
        - case
      example: book
      type: string
const isBook = (type: DocumentTypeEnum): boolean => type === DocumentTypeEnum.Book;

const typeName = (type: DocumentTypeEnum): string => {
  switch (type) {
    case DocumentTypeEnum.Book:
      return '書籍'
    case DocumentTypeEnum.Law:
      return '法律'
    case DocumentTypeEnum.Case:
      return '判例'
  }
}

良かった所

  • 微妙な定義の齟齬が発生する事がわかったので今後はそれがなくなるという希望を得られた
  • レポジトリ層の実装が統一できた
  • *.vue ファイルから API アクセスを撲滅できた
  • openapi.yaml の定義が充実した
  • この後のレポジトリ層の実装が爆速になりそう

Set の呪いと Enum

typescript-axios は非常に使いやすいのですが、 2点ほど困った事があります

ひとつ目は type: arrayuniqueItems: true が設定されていると Array<T> ではなく Set<T> として型定義を作ってしまう事です。 axios 自体は配列は配列としてしかパースしないため実際のデータ構造と型情報に矛盾ができてしまいます

もともと配列として実装されているので型エラーが発生した箇所で new Array(set_data) を実行する事で型を正常に戻す事ができていますが、今後新しい実装をする際に型情報が間違っていて落とし穴にハマりそうなのでなんとかして対応する必要があります

もうひとつの問題は union ではなく Enum で列挙型が定義されてしまう点です。こちらに関しては実装上の問題はないのですが、フロントエンドにおいて Enum の実装はバッドプラクティスとされているのでできれば避けたい所。 こちらの記事 にもありますが typescript-angular であればユニオン型で生成してくれるようなので、なんとかそれを取り込めないかとチャレンジしたい気持ちはあります

最後に

どうしてもモデルなどの型定義はお互いが依存してしまうため、一気にすべてを修正してしまわないといけません。数は多かったですが、幸い openapi.yaml の定義もフロントエンドの型定義もかなり詳細に定義されていたので着手前の想定よりはスムーズに実装できたかと思います

置き換えた直後は型エラーが鬼のように出現するので心が折れそうになると思いますが、「型定義は書き換えても動作に変更はない」ということを心に刻んで恐れずに取り掛かれば大丈夫です

今のタイミングで置き換えができたのは結構ギリギリのタイミングだったと思います。これ以上 API やモデルの数が増えてくると直しきれなかったと思うので、もし検討しているのであればなるべく早く着手することをお勧めします

今回はフロントエンドコードでの対応について取り上げましたが 22日に予定している後半 ではフロントエンドに優しい openapi.yaml の記述方法を取り上げる予定なのでぜひそちらもお読みください


Legalscape ではフロントエンドの枠にとどまらず型について語り合えるようなエンジニアを募集しています。興味がある型はぜひコンタクトお待ちしております

Discussion

ログインするとコメントできます