Code generator開発記(Part 1)
!
この記事は2023年6月19日に執筆されたものです。
こんにちは。Inflabのフロントエンドエンジニア、Lucasです。
フロントエンド開発をしていると、code generatorという言葉を耳にしたことがあると思います。Code generatorとは、サーバー側で宣言され、クライアントとの取り決めとなるデータ型をもとに、クライアントで使えるコードを自動生成するプログラムのことです。特にフロントエンドエンジニアの立場から見ると、APIの変更を追跡し、type-safeな開発をしたいときに、こうした自動化ツールは大きなメリットをもたらします。
Inflabでも、本格的にレガシーシステムの改善を進めるようになってから、APIの型を扱う必要が出てきました。1台のサーバーで動いていたInflearnがAPIサーバーとクライアントに分かれはじめ、APIを通じて機能を実装しなければならない場面が増えたためです。
本記事では、code generatorを自分たちで開発することになった理由についてお話しします。次回の記事では、実装方法と導入後に何が変わったのかを共有する予定です。
1. RESTful APIと向き合う
プロダクトを作っていると多くの悩みに直面しますが、その中でもAPIリクエストまわりをどう管理するかには、いつも頭を悩ませます。
まずどのライブラリを使うかに始まり、コードをどこにどう配置すればAPIの変化に簡単かつ柔軟に対応できるかといった、正解のない選択が求められます。加えて、時間が経つにつれて増えていくAPI endpointの管理も、悩みの種の一つになりがちです。
コードベースでの悩み
特に、今や業界標準と言えるTypeScriptを使う場合は、APIの戻り値の型をどう管理するかという悩みがもう一つ加わります。
TypeScriptの特性上、APIの戻り値の型を指定しないままでは使えないので、必ず宣言する必要があります。そこでフロントエンドエンジニアは、通常2つの方法のどちらかを選ぶことになります。1つはすべてのリクエストの戻り値の型をanyで宣言する方法、もう1つはドキュメントに記載された戻り値を見て型を手動で管理する方法です。
経験上、型をanyにせず手動で管理すると、問題が次々に発生していました。
プロジェクト初期は型をこまめに更新する
プロジェクトが終盤に近づくにつれ、さまざまな理由(忙しい、急ぎのバグ修正など)で、型をコンパイルが失敗しない程度に適当に管理する
リリース後に保守が進むにつれ、新しい型が量産されたり、index signature、union、intersectionなどが乱用されたりして、runtimeの安定性が徐々に低下する
最終的に型宣言そのものがレガシーとなり、誰も気にしなくなる
もちろんすべてのプロジェクトで起こることではありませんが、おおむね上のような流れをたどった場合、APIとクライアント間のインターフェースの役割を担う型は有名無実化してしまいます。
つまり、APIとクライアントの間の役割、責任、協調を信頼できなくなるということです。プロダクトのために最も密接に協力すべき2つのオブジェクトが、互いを信頼できなくなってしまうのです。
フロントエンドエンジニア同士の不毛な議論
型を手動で管理するとなると、人や連携するチームごとに、管理方法の考え方ややり方が異なることがあります。そのため、型管理の一貫性を保つには、少なくとも連携するチーム同士で、ひいてはフロントエンドチーム全体で合意する必要があります。
チーム全体で合意できたとしても、その合意が盤石とは限りません。もし誰かが新しい管理方法を提案したら、また長くて退屈な議論を一からやり直すべきでしょうか?
もちろん、こうした技術的な議論そのものが悪いわけではありませんが、個人的にはこの種の議論は正解がなく、消耗するだけだと考えています。この問題を解決する自動化ツールがあれば、議論そのものが不要になるからです。
もしこうした問題をツールで解決できれば、その時間をほかの議論に使えます。たとえばプロダクトの使いやすさ、コードをより良く書く方法、デプロイ時間を短縮する方法といった「ほかの議論」のほうが、プロダクトそのものの生産性に、より大きな影響を与えられます。
フロントエンドエンジニアとバックエンドエンジニアの水掛け論
通常、RESTful APIを提供するサーバーを構築すると、ほとんどの場合ドキュメントツールとしてSwaggerを使うことになります。面白いことに、Swaggerを使うと高い確率でフロントエンドエンジニアとバックエンドエンジニアの間にA-chicken-and-egg-problem(鶏が先か卵が先か)が発生します。
たとえば、API開発者も人間なので、当然ながら構築されたAPIとSwaggerで提供されるドキュメント上の仕様が異なるケースが起こりえます。また、フロントエンドエンジニアもそれぞれ仕事のスタイルが違うので、Swaggerで提供されるドキュメントを見ずに開発するケースも出てきます。
こうなると、「どうせ見ないのにドキュメントを更新して何になるのか」という考えと、「どうせ合っていないのに、なぜドキュメントを見るのか」という考えを互いに持つようになります。あとになって「どうしてこうなったのだろう?」と頭を抱えますが、この問題の明確な原因は結局分からないままのことが多いです。
結局、フロントエンドエンジニアはAPIが返す値を自分で確認しながら型を書くことになります。いちばん確実な方法のように思えますが、いくつか問題が生じます。
1つは、プロパティの値がnullやundefinedになりうるという問題です。たとえば次のようなレスポンスがある場合、
{
"user": {
"name": "user name"
}
}
もしuserプロパティがnullになりうるなら、以下のようなコードで問題が発生する可能性があります。
user.name.split(' ');
// Uncaught TypeError: Cannot read properties of null (reading 'name')
もう1つは、プロパティに異なる型の値が入りうるという問題です。同じく、たとえば以下のようなレスポンスがある場合、
{
"user": {
"age": 20
}
}
もしageプロパティがnumberまたはboolean型になりうるなら、以下のようなコードで問題が発生する可能性があります。
user.age > 0;
// ageが20の場合はtrue
// ageがfalseの場合はfalse
こうなると、実際にruntimeでエラーが発生するまでAPIの変更に気づけない可能性が出てきます。もちろんTypeScriptがruntimeの安定性を完全に保証するわけではありませんが、最低限の安全装置が緩んでしまうのです。
RESTful APIは間違いなく良いコンセプトだと思います。ただ、いつものように問題は人にあります。そこで、この問題から人の手をできるだけ取り除くことを試みました。
2. 検討した解決策
以前使ったことのある技術の中から、今のチームに適用できそうな解決策をいくつか検討しましたが、どれも採用には至りませんでした。
GraphQL
個人的には、GraphQLがこの問題を解決する最もエレガントかつ確実な方法だと考えていました。以前のチームでGraphQLを使ってBFF(Backend For Frontend)サーバーを構築・運用した経験があり、サイドプロジェクトでも積極的に使っていたので、なじみのない技術でもありませんでした。
CTOのHyangLoに恐る恐る導入について相談してみましたが、結局却下されました。正確には、社内サービスで使うのは問題ないものの、外部に公開するサービスには導入できそうにないという回答でした。その理由はいくつかあり、以下のとおりでした。
GraphQLを使うと、DBのモデリングがリクエストの結果を通じて大きく露出してしまう。
GraphQLはORMに過度に依存する。
どの程度までモニタリングできるのか、現在使っているモニタリングツールがGraphQLをきちんとサポートしているのかが分からない。
BFFはサービスレイヤーを増やすため、チームの規模を考えると管理の負担になりうる。
GraphQLの経験者がバックエンドチーム、フロントエンドチームにそれぞれ1人ずつしかいない。学習コストがやや高そうなうえ、経験者が少ないことが導入の障壁になりかねない。
ビジネスの状況が急に変わることもありうるのに、開発チームがGraphQLへの移行のせいでスピードを合わせられなくなるおそれがある。
技術的な理由には個人的に腑に落ちない点もありましたが、それ以外は十分に納得できる理由でした。しかもInflabはここ最近スピード感を持って動いていたので、新しい技術スタックの導入が足かせになることは避けなければなりませんでした。
特に、レガシーAPIを段階的に改善しなければならない状況では、GraphQLの単一エンドポイントがデメリットになる可能性が高いと感じました。エンドポイントがURLで分かれていればURLごとにAPIのtarget groupを切り替えながら改善できますが、エンドポイントが1つだとこうした戦略は取れないからです。
Client side GraphQL
Client side GraphQLも検討対象に含めましたが、結局自ら見送りました。クライアントコードに含まれるGraphQL schemaがAPIのインターフェースとして機能することはできても、resolverでAPIを呼び出す必要があるため、根本的な問題の解決策にはならないと判断したからです。
gRPC
gRPCもGraphQLと同じ観点から検討しましたが、これを使ってAPIを実装することになるバックエンドエンジニアにとっては、GraphQLよりもさらになじみのない方法だとすぐに気づきました。同じセル(Inflab内の小規模なチーム単位)のバックエンドエンジニアと話し合ったところ、GraphQLほど前向きな反応は得られなかったからです。プロダクトの安定性も大事ですが、だからといって忙しいバックエンドチームになじみのない技術を勧めるわけにはいきませんでした。
3. コミュニティからヒントを得る
GraphQLコミュニティには、The Guildというオープンソース開発者の集団があります。The GuildはGraphQL関連の強力なOSSを数多くメンテナンスしており、その一つがCode generatorです。
GraphQLを使っていたときに大いに助けられたライブラリだったので、OpenAPI Specificationを対象にした似たようなライブラリがあるかもしれないと考えました。案の定、openapi-generatorというライブラリがすぐに見つかりました。
ライブラリをインストールして試してみるまでに、それほど時間はかかりませんでした。openapi-generatorを導入したベストプラクティスを紹介する記事が非常に多く、簡単に使えたからです。いろいろなオプションを見れば見るほど、本当によくできたライブラリだと思うようになりました。
4. 車輪の再発明を決断する
個人的には、車輪の再発明はかなり避けるほうです。しかし今回は少し事情が違いました。openapi-generatorを試す中で、不満な点が見つかったからです。
1. 生成物への不満
まず一番気になったのは、生成結果が過剰で煩雑すぎることです。もちろんオプションでいろいろ調整することはできるでしょうが、満足のいく形になるとは思えませんでした。何より、GraphQL code generatorのようにAPIを呼び出すhooksまで生成されることを期待していたのですが、調べた限りではそういった機能はないようでした。
以下のようなGraphQLスキーマがあるとすると、
schema {
query: Query
}
type Query {
me: User!
}
enum Role {
USER
ADMIN
}
type User {
id: ID!
username: String!
email: String!
role: Role!
}
query findUser {
me {
...UserFields
}
}
fragment UserFields on User {
id
username
role
}
GraphQL code generatorを使えば、通常は以下のようなコードが生成されるので、とても手軽に使えます。
import { useQuery, UseQueryOptions } from '@tanstack/react-query';
export type Maybe<T> = T | null;
export type InputMaybe<T> = Maybe<T>;
export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
export type MakeOptional<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]?: Maybe<T[SubKey]> };
export type MakeMaybe<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]: Maybe<T[SubKey]> };
export type MakeEmpty<T extends { [key: string]: unknown }, K extends keyof T> = {
[_ in K]?: never;
};
export type Incremental<T> =
| T
| { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never };
function fetcher<TData, TVariables>(
endpoint: string,
requestInit: RequestInit,
query: string,
variables?: TVariables,
) {
return async (): Promise<TData> => {
const res = await fetch(endpoint, {
method: 'POST',
...requestInit,
body: JSON.stringify({ query, variables }),
});
const json = await res.json();
if (json.errors) {
const { message } = json.errors[0];
throw new Error(message);
}
return json.data;
};
}
/** All built-in and custom scalars, mapped to their actual values */
export type Scalars = {
ID: { input: string; output: string };
String: { input: string; output: string };
Boolean: { input: boolean; output: boolean };
Int: { input: number; output: number };
Float: { input: number; output: number };
};
export type Query = {
__typename?: 'Query';
me: User;
};
export enum Role {
User = 'USER',
Admin = 'ADMIN',
}
export type User = {
__typename?: 'User';
id: Scalars['ID']['output'];
username: Scalars['String']['output'];
email: Scalars['String']['output'];
role: Role;
};
export type FindUserQueryVariables = Exact<{ [key: string]: never }>;
export type FindUserQuery = {
__typename?: 'Query';
me: { __typename?: 'User'; id: string; username: string; role: Role };
};
export type UserFieldsFragment = { __typename?: 'User'; id: string; username: string; role: Role };
export const UserFieldsFragmentDoc = `
fragment UserFields on User {
id
username
role
}
`;
export const FindUserDocument = `
query findUser {
me {
...UserFields
}
}
${UserFieldsFragmentDoc}`;
export const useFindUserQuery = <TData = FindUserQuery, TError = unknown>(
dataSource: { endpoint: string; fetchParams?: RequestInit },
variables?: FindUserQueryVariables,
options?: UseQueryOptions<FindUserQuery, TError, TData>,
) =>
useQuery<FindUserQuery, TError, TData>(
variables === undefined ? ['findUser'] : ['findUser', variables],
fetcher<FindUserQuery, FindUserQueryVariables>(
dataSource.endpoint,
dataSource.fetchParams || {},
FindUserDocument,
variables,
),
options,
);
import { useFindUserQuery } from './schema.ts';
function User() {
const { data } = useFindUserQuery();
return <p>{data?.username}</p>;
}
自動生成されたhooksを使う手軽さと安心感を知っていたので、OpenAPI specificationを使う場合でも、同じような結果を得たいと考えました。
2. Inflab独自の要件
私たちのチーム独自の要件が必ず出てくると予想していました。たとえばセルごとにエラー処理の方法が異なったり、生成物を1つのファイルにまとめたい、構造を変えたい、生成される型の名前を変えたいといった厄介な要望が出てきたりするはずだと考えていました。
これはmustacheとcustom templateで緩和することはできましたが、この問題を完全に解決できるとは思えませんでした。
かなり長い期間悩み続け、さまざまな状況を総合的に考えた結果、一度自分たちで作ってみようと決心しました。こうしてcode generatorへの旅が始まりました。
5. 目標
Code generatorを作る前に立てた目標は以下のとおりです。もちろん最初のバージョンですべてを実装することはできないでしょうが、最終的にv1には以下の機能がすべて含まれていてほしいと考えていました。
ファイルまたはネットワークリクエストでOpenAPI specification JSONを直接パースし、望む形の結果をファイルとして生成できること。
フロントエンドエンジニアが、APIのschemaの変化にできるだけ手間なく対応できること。
フロントエンドエンジニアが、生成物として出力したいAPIを選択できること。
APIで宣言したresponseをJSON validatorで検証できるtype guardが自動生成されること。
バックエンドエンジニアが、フロントエンドの手間を気にせずAPIを作れること。
バックエンドエンジニアが宣言したSwaggerと実際のAPI responseの形が一致しているかを、integration testで検証できること。
特に最後の機能は、GraphQLとは違ってSwaggerで宣言したschemaがresponse typeの整合性を保証してくれないため、どうしても必要だと考えました。Type guardもAPIのリクエスト結果と直接の関係はありませんが、フロントエンドでAPI schemaごとにいちいちtype guardを作る手間を省きたかったのです。
詳しい実装方法と導入後の話は、続く記事でご紹介したいと思います。
Mentions
Claude君のアホ!確認しなかった私もわるいんだけど! Hasuraの証明書CAで見つかったバグについて、社内GitHubに報告しておいてーのつもりでバグ報告お願いしたら、 Hasura本家に報告してた.... 幸いHasuraコントリビューターが親切に対応してくれたので事なきを得たけども graphql-engine#10901
Code generator開発記(Part 1) ! この記事は2023年6月19日に執筆されたものです。 こんにちは。Inflabのフロントエンドエンジニア、Lucasです。 フロントエンド開発をしていると、code generatorという言葉を耳にしたことがあると思います。Code generatorとは、サーバー側で宣言され、クライアントとの取り決めとなるデータ型をもとに、クライアントで使えるコードを自動生成するプログラムのことです。特にフロントエンドエンジニアの立場から見ると、APIの変更を追跡し、type-safeな開発をしたいときに、こうした自動化ツールは大きなメリットをもたらします。 Inflabでも、本格的にレガシーシステムの改善を進めるようになってから、APIの型を扱う必要が出てきました。1台のサーバーで動いていたInflearnがAPIサーバーとクライアントに分かれはじめ、APIを通じて機能を実装しなければならない場面が増えたためです。 本記事では、code generatorを自分たちで開発することになった理由についてお話しします。次回の記事では、実装方法と導入後に何が変わったのかを共有する予定です。 1. RESTful APIと向き合う プロダクトを作っていると多くの悩みに直面しますが、その中でもAPIリクエストまわりをどう管理するかには、いつも頭を悩ませます。 まずどのライブラリを使うかに始まり、コードをどこにどう配置すればAPIの変化に簡単かつ柔軟に対応できるかといった、正解のない選択が求められます。加えて、時間が経つにつれて増えていくAPI endpointの管理も、悩みの種の一つになりがちです。 コードベースでの悩み 特に、今や業界標準と言えるTypeScriptを使う場合は、APIの戻り値の型をどう管理するかという悩みがもう一つ加わります。 TypeScriptの特性上、APIの戻り値の型を指定しないままでは使えないので、必ず宣言する必要があります。そこでフロントエンドエンジニアは、通常2つの方法のどちらかを選ぶことになります。1つはすべてのリクエストの戻り値の型をanyで宣言する方法、もう1つはドキュメントに記載された戻り値を見て型を手動で管理する方法です。 経験上、型をanyにせず手動で管理すると、問題が次々に発生していました。 プロジェクト初期は型をこまめに更新する プロジェクトが終盤に近づくにつれ、さまざまな理由(忙しい、急ぎのバグ修正など)で、型をコンパイルが失敗しない程度に適当に管理する リリース後に保守が進むにつれ、新しい型が量産されたり、index signature、union、intersectionなどが乱用されたりして、runtimeの安定性が徐々に低下する 最終的に型宣言そのものがレガシーとなり、誰も気にしなくなる もちろんすべてのプロジェクトで起こることではありませんが、おおむね上のような流れをたどった場合、APIとクライアント間のインターフェースの役割を担う型は有名無実化してしまいます。 つまり、APIとクライアントの間の役割、責任、協調を信頼できなくなるということです。プロダクトのために最も密接に協力すべき2つのオブジェクトが、互いを信頼できなくなってしまうのです。 フロントエンドエンジニア同士の不毛な議論 型を手動で管理するとなると、人や連携するチームごとに、管理方法の考え方ややり方が異なることがあります。そのため、型管理の一貫性を保つには、少なくとも連携するチーム同士で、ひいてはフロントエンドチーム全体で合意する必要があります。 チーム全体で合意できたとしても、その合意が盤石とは限りません。もし誰かが新しい管理方法を提案したら、また長くて退屈な議論を一からやり直すべきでしょうか? もちろん、こうした技術的な議論そのものが悪いわけではありませんが、個人的にはこの種の議論は正解がなく、消耗するだけだと考えています。この問題を解決する自動化ツールがあれば、議論そのものが不要になるからです。 もしこうした問題をツールで解決できれば、その時間をほかの議論に使えます。たとえばプロダクトの使いやすさ、コードをより良く書く方法、デプロイ時間を短縮する方法といった「ほかの議論」のほうが、プロダクトそのものの生産性に、より大きな影響を与えられます。 フロントエンドエンジニアとバックエンドエンジニアの水掛け論 通常、RESTful APIを提供するサーバーを構築すると、ほとんどの場合ドキュメントツールとしてSwaggerを使うことになります。面白いことに、Swaggerを使うと高い確率でフロントエンドエンジニアとバックエンドエンジニアの間にA-chicken-and-egg-problem(鶏が先か卵が先か)が発生します。 たとえば、API開発者も人間なので、当然ながら構築されたAPIとSwaggerで提供されるドキュメント上の仕様が異なるケースが起こりえます。また、フロントエンドエンジニアもそれぞれ仕事のスタイルが違うので、Swaggerで提供されるドキュメントを見ずに開発するケースも出てきます。 こうなると、「どうせ見ないのにドキュメントを更新して何になるのか」という考えと、「どうせ合っていないのに、なぜドキュメントを見るのか」という考えを互いに持つようになります。あとになって「どうしてこうなったのだろう?」と頭を抱えますが、この問題の明確な原因は結局分からないままのことが多いです。 結局、フロントエンドエンジニアはAPIが返す値を自分で確認しながら型を書くことになります。いちばん確実な方法のように思えますが、いくつか問題が生じます。 1つは、プロパティの値がnullやundefinedになりうるという問題です。たとえば次のようなレスポンスがある場合、 { "user": { "name": "user name" } } もしuserプロパティがnullになりうるなら、以下のようなコードで問題が発生する可能性があります。 user.name.split(' '); // Uncaught TypeError: Cannot read properties of null (reading 'name') もう1つは、プロパティに異なる型の値が入りうるという問題です。同じく、たとえば以下のようなレスポンスがある場合、 { "user": { "age": 20 } } もしageプロパティがnumberまたはboolean型になりうるなら、以下のようなコードで問題が発生する可能性があります。 user.age > 0; // ageが20の場合はtrue // ageがfalseの場合はfalse こうなると、実際にruntimeでエラーが発生するまでAPIの変更に気づけない可能性が出てきます。もちろんTypeScriptがruntimeの安定性を完全に保証するわけではありませんが、最低限の安全装置が緩んでしまうのです。 RESTful APIは間違いなく良いコンセプトだと思います。ただ、いつものように問題は人にあります。そこで、この問題から人の手をできるだけ取り除くことを試みました。 2. 検討した解決策 以前使ったことのある技術の中から、今のチームに適用できそうな解決策をいくつか検討しましたが、どれも採用には至りませんでした。 GraphQL 個人的には、GraphQLがこの問題を解決する最もエレガントかつ確実な方法だと考えていました。以前のチームでGraphQLを使ってBFF(Backend For Frontend)サーバーを構築・運用した経験があり、サイドプロジェクトでも積極的に使っていたので、なじみのない技術でもありませんでした。 CTOのHyangLoに恐る恐る導入について相談してみましたが、結局却下されました。正確には、社内サービスで使うのは問題ないものの、外部に公開するサービスには導入できそうにないという回答でした。その理由はいくつかあり、以下のとおりでした。 GraphQLを使うと、DBのモデリングがリクエストの結果を通じて大きく露出してしまう。 GraphQLはORMに過度に依存する。 どの程度までモニタリングできるのか、現在使っているモニタリングツールがGraphQLをきちんとサポートしているのかが分からない。 BFFはサービスレイヤーを増やすため、チームの規模を考えると管理の負担になりうる。 GraphQLの経験者がバックエンドチーム、フロントエンドチームにそれぞれ1人ずつしかいない。学習コストがやや高そうなうえ、経験者が少ないことが導入の障壁になりかねない。 ビジネスの状況が急に変わることもありうるのに、開発チームがGraphQLへの移行のせいでスピードを合わせられなくなるおそれがある。 技術的な理由には個人的に腑に落ちない点もありましたが、それ以外は十分に納得できる理由でした。しかもInflabはここ最近スピード感を持って動いていたので、新しい技術スタックの導入が足かせになることは避けなければなりませんでした。 特に、レガシーAPIを段階的に改善しなければならない状況では、GraphQLの単一エンドポイントがデメリットになる可能性が高いと感じました。エンドポイントがURLで分かれていればURLごとにAPIのtarget groupを切り替えながら改善できますが、エンドポイントが1つだとこうした戦略は取れないからです。 Client side GraphQL Client side GraphQLも検討対象に含めましたが、結局自ら見送りました。クライアントコードに含まれるGraphQL schemaがAPIのインターフェースとして機能することはできても、resolverでAPIを呼び出す必要があるため、根本的な問題の解決策にはならないと判断したからです。 gRPC gRPCもGraphQLと同じ観点から検討しましたが、これを使ってAPIを実装することになるバックエンドエンジニアにとっては、GraphQLよりもさらになじみのない方法だとすぐに気づきました。同じセル(Inflab内の小規模なチーム単位)のバックエンドエンジニアと話し合ったところ、GraphQLほど前向きな反応は得られなかったからです。プロダクトの安定性も大事ですが、だからといって忙しいバックエンドチームになじみのない技術を勧めるわけにはいきませんでした。 3. コミュニティからヒントを得る GraphQLコミュニティには、The Guildというオープンソース開発者の集団があります。The GuildはGraphQL関連の強力なOSSを数多くメンテナンスしており、その一つがCode generatorです。 GraphQLを使っていたときに大いに助けられたライブラリだったので、OpenAPI Specificationを対象にした似たようなライブラリがあるかもしれないと考えました。案の定、openapi-generatorというライブラリがすぐに見つかりました。 ライブラリをインストールして試してみるまでに、それほど時間はかかりませんでした。openapi-generatorを導入したベストプラクティスを紹介する記事が非常に多く、簡単に使えたからです。いろいろなオプションを見れば見るほど、本当によくできたライブラリだと思うようになりました。 4. 車輪の再発明を決断する 個人的には、車輪の再発明はかなり避けるほうです。しかし今回は少し事情が違いました。openapi-generatorを試す中で、不満な点が見つかったからです。 1. 生成物への不満 まず一番気になったのは、生成結果が過剰で煩雑すぎることです。もちろんオプションでいろいろ調整することはできるでしょうが、満足のいく形になるとは思えませんでした。何より、GraphQL code generatorのようにAPIを呼び出すhooksまで生成されることを期待していたのですが、調べた限りではそういった機能はないようでした。 以下のようなGraphQLスキーマがあるとすると、 schema { query: Query } type Query { me: User! } enum Role { USER ADMIN } type User { id: ID! username: String! email: String! role: Role! } query findUser { me { ...UserFields } } fragment UserFields on User { id username role } GraphQL code generatorを使えば、通常は以下のようなコードが生成されるので、とても手軽に使えます。 import { useQuery, UseQueryOptions } from '@tanstack/react-query'; export type Maybe<T> = T | null; export type InputMaybe<T> = Maybe<T>; export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] }; export type MakeOptional<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]?: Maybe<T[SubKey]> }; export type MakeMaybe<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]: Maybe<T[SubKey]> }; export type MakeEmpty<T extends { [key: string]: unknown }, K extends keyof T> = { [_ in K]?: never; }; export type Incremental<T> = | T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }; function fetcher<TData, TVariables>( endpoint: string, requestInit: RequestInit, query: string, variables?: TVariables, ) { return async (): Promise<TData> => { const res = await fetch(endpoint, { method: 'POST', ...requestInit, body: JSON.stringify({ query, variables }), }); const json = await res.json(); if (json.errors) { const { message } = json.errors[0]; throw new Error(message); } return json.data; }; } /** All built-in and custom scalars, mapped to their actual values */ export type Scalars = { ID: { input: string; output: string }; String: { input: string; output: string }; Boolean: { input: boolean; output: boolean }; Int: { input: number; output: number }; Float: { input: number; output: number }; }; export type Query = { __typename?: 'Query'; me: User; }; export enum Role { User = 'USER', Admin = 'ADMIN', } export type User = { __typename?: 'User'; id: Scalars['ID']['output']; username: Scalars['String']['output']; email: Scalars['String']['output']; role: Role; }; export type FindUserQueryVariables = Exact<{ [key: string]: never }>; export type FindUserQuery = { __typename?: 'Query'; me: { __typename?: 'User'; id: string; username: string; role: Role }; }; export type UserFieldsFragment = { __typename?: 'User'; id: string; username: string; role: Role }; export const UserFieldsFragmentDoc = ` fragment UserFields on User { id username role } `; export const FindUserDocument = ` query findUser { me { ...UserFields } } ${UserFieldsFragmentDoc}`; export const useFindUserQuery = <TData = FindUserQuery, TError = unknown>( dataSource: { endpoint: string; fetchParams?: RequestInit }, variables?: FindUserQueryVariables, options?: UseQueryOptions<FindUserQuery, TError, TData>, ) => useQuery<FindUserQuery, TError, TData>( variables === undefined ? ['findUser'] : ['findUser', variables], fetcher<FindUserQuery, FindUserQueryVariables>( dataSource.endpoint, dataSource.fetchParams || {}, FindUserDocument, variables, ), options, ); import { useFindUserQuery } from './schema.ts'; function User() { const { data } = useFindUserQuery(); return <p>{data?.username}</p>; } 自動生成されたhooksを使う手軽さと安心感を知っていたので、OpenAPI specificationを使う場合でも、同じような結果を得たいと考えました。 2. Inflab独自の要件 私たちのチーム独自の要件が必ず出てくると予想していました。たとえばセルごとにエラー処理の方法が異なったり、生成物を1つのファイルにまとめたい、構造を変えたい、生成される型の名前を変えたいといった厄介な要望が出てきたりするはずだと考えていました。 これはmustacheとcustom templateで緩和することはできましたが、この問題を完全に解決できるとは思えませんでした。 かなり長い期間悩み続け、さまざまな状況を総合的に考えた結果、一度自分たちで作ってみようと決心しました。こうしてcode generatorへの旅が始まりました。 5. 目標 Code generatorを作る前に立てた目標は以下のとおりです。もちろん最初のバージョンですべてを実装することはできないでしょうが、最終的にv1には以下の機能がすべて含まれていてほしいと考えていました。 ファイルまたはネットワークリクエストでOpenAPI specification JSONを直接パースし、望む形の結果をファイルとして生成できること。 フロントエンドエンジニアが、APIのschemaの変化にできるだけ手間なく対応できること。 フロントエンドエンジニアが、生成物として出力したいAPIを選択できること。 APIで宣言したresponseをJSON validatorで検証できるtype guardが自動生成されること。 バックエンドエンジニアが、フロントエンドの手間を気にせずAPIを作れること。 バックエンドエンジニアが宣言したSwaggerと実際のAPI responseの形が一致しているかを、integration testで検証できること。 特に最後の機能は、GraphQLとは違ってSwaggerで宣言したschemaがresponse typeの整合性を保証してくれないため、どうしても必要だと考えました。Type guardもAPIのリクエスト結果と直接の関係はありませんが、フロントエンドでAPI schemaごとにいちいちtype guardを作る手間を省きたかったのです。 詳しい実装方法と導入後の話は、続く記事でご紹介したいと思います。