openapi-generator を使ってフロントエンドの api を自動生成コードに置き換える
この記事は 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
などのオプションをを指定して、 api
と model
がフォルダに生成されるようにします
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 しておきます
# .openapi-generator-ignore
git_push.sh
.gitignore
.npmignore
同じコードベースに openapi.yaml
が存在していればいいですが、そうじゃない場合はどうにかして持ってこないと行けません。今回は Makefile
を利用してビルドのためのコマンドを集約しました。 openapi.yaml
は別レポジトリに存在しているので curl
コマンドで取得するようにします
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
を直接レポジトリとして注入しているなどのものもありました
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);
})
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;
}
}
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ステップで完了です
- レポジトリのコンストラクタ or 生成メソッドの引数を
axiosInstance
から自動生成したクラスに変更する - レポジトリの中で
axiosInstance.get()
などを呼んでいた箇所を自動生成したものに置き換える
レスポンスの型定義が openapi.yaml
で定義したものと齟齬がなければエラーは発生しないはずです。もしエラーが発生したら、それは定義と実装に不整合があるという事なので、改善のチャンスと思って次のセクションの要領で対応しましょう
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
を通している箇所があったらそれは不要になるのでご注意ください
型定義を置き換える
問題なければ、レスポンスやモデルの型などバックエンドの型定義をフロントで再定義しているものを削除、自動生成したものに置き換えればそこで作業は完了です
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
を利用します
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
では string
や integer
で指定している場合、情報が劣化して型エラーが発生します。その場合は enum を設定する事で中に含まれる値を限定する事ができます
switch
文の default
が不要になったりするので置き換えていきましょう。 Legalscape の環境では同値の別の Enum の比較が型エラーにならないですが、設定によっては型エラーになるようなので、環境によってより安全なコードベースを維持できると思います
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: array
で uniqueItems: true
が設定されていると Array<T>
ではなく Set<T>
として型定義を作ってしまう事です。 axios
自体は配列は配列としてしかパースしないため実際のデータ構造と型情報に矛盾ができてしまいます
もともと配列として実装されているので型エラーが発生した箇所で new Array(set_data)
を実行する事で型を正常に戻す事ができていますが、今後新しい実装をする際に型情報が間違っていて落とし穴にハマりそうなのでなんとかして対応する必要があります
もうひとつの問題は union ではなく Enum で列挙型が定義されてしまう点です。こちらに関しては実装上の問題はないのですが、フロントエンドにおいて Enum の実装はバッドプラクティスとされているのでできれば避けたい所。 こちらの記事 にもありますが typescript-angular
であればユニオン型で生成してくれるようなので、なんとかそれを取り込めないかとチャレンジしたい気持ちはあります
最後に
どうしてもモデルなどの型定義はお互いが依存してしまうため、一気にすべてを修正してしまわないといけません。数は多かったですが、幸い openapi.yaml
の定義もフロントエンドの型定義もかなり詳細に定義されていたので着手前の想定よりはスムーズに実装できたかと思います
置き換えた直後は型エラーが鬼のように出現するので心が折れそうになると思いますが、「型定義は書き換えても動作に変更はない」ということを心に刻んで恐れずに取り掛かれば大丈夫です
今のタイミングで置き換えができたのは結構ギリギリのタイミングだったと思います。これ以上 API やモデルの数が増えてくると直しきれなかったと思うので、もし検討しているのであればなるべく早く着手することをお勧めします
今回はフロントエンドコードでの対応について取り上げましたが 22日に予定している後半 ではフロントエンドに優しい openapi.yaml
の記述方法を取り上げる予定なのでぜひそちらもお読みください
Legalscape ではフロントエンドの枠にとどまらず型について語り合えるようなエンジニアを募集しています。興味がある型はぜひコンタクトお待ちしております
Discussion