Code generator開発記(Part 2)
!
この記事は2023年7月3日に執筆されたものです。
こんにちは。Inflabのフロントエンドエンジニア、Lucasです。
前回の記事では、code generatorの必要性と、自前で実装するに至るまでの悩みを共有しました。今回は、code generatorをはじめ、schema validator、type guardをどのように実装したのかを簡単にご紹介したいと思います。
最初にお伝えしておくと、これはとてもシンプルなアイデアで、実装に複雑な技術は必要ありません。その前提で読み進めていただければと思います。
1. 目標と必要な機能
まず、前回の記事で立てた目標について改めて考えてみます。私はcode generatorを実装する前に、次のような目標を立てました。
ファイルまたはネットワークリクエストでOpenAPI specification JSONを直接パースし、望む形の結果をファイルとして生成できること。
フロントエンドエンジニアが、APIのschemaの変化にできるだけ手間なく対応できること。
フロントエンドエンジニアが、生成物として出力したいAPIを選択できること。
APIで宣言したresponseをJSON validatorで検証できるtype guardが自動生成されること。
バックエンドエンジニアが、フロントエンドの手間を気にせずAPIを作れること。
バックエンドエンジニアが宣言したSwaggerと実際のAPI responseの形が一致しているかを、integration testで検証できること。
特に最後の目標は、Elixirのdoc testの概念を借りたものです。昨年取り組んだ個人プロジェクトでテストを書いた際の感触が良く、GraphQLがサポートしているようなschemaの整合性検証も、近い形なら実現できるのではないかと期待していました。特に私たちのバックエンドチームはテストを書くことを非常に重視しているので、OpenAPI specificationと実際のAPIの結果をテストする手軽な方法を提供できるだろうと考えました。
以下、OpenAPI specification JSONはOASと略します。
2. 使用したライブラリ
まず、実装に使ったライブラリを紹介します。おそらくほとんどの方がご存じだと思います。
1. 実装
inversify
機能が増えるほど、コードベースは必然的に大きく複雑になるだろうと考えました。また、複数のオプションをサポートする予定だったので、実行時に動く実装も変わりうると見ていました。
そのため機能間の依存関係が複雑になりそうだと考え、悩んだ末に依存性注入(DI)のためのIoCコンテナを探すことにしました。私はinversifyを選びましたが、もう少し軽量に使いたいのであれば、typediという選択肢もあります。
json-schema-to-typescript
事実上、code generatorの中核となるライブラリです。Code generatorのほぼすべての実装は、このライブラリから望む結果を得るためのオブジェクトを作る作業だと言っても過言ではありません。例えば、次のようなJSON schemaがある場合、
{
"title": "Example Schema",
"type": "object",
"properties": {
"firstName": {
"type": "string"
},
"lastName": {
"type": "string"
},
"age": {
"description": "Age in years",
"type": "integer",
"minimum": 0
},
"hairColor": {
"enum": ["black", "brown", "blue"],
"type": "string"
}
},
"additionalProperties": false,
"required": ["firstName", "lastName"]
}
次のように型を生成します。
export interface ExampleSchema {
firstName: string;
lastName: string;
/**
- Age in years
*/
age?: number;
hairColor?: 'black' | 'brown' | 'blue';
}
lodash
object-traversal
js-yaml
オプションをYAML形式で受け取るつもりでインストールしました。今思えば、普通にJavaScriptで受け取っても問題なかったと思います。
jsonschema
Code generatorが受け付けられるオプションを検証するために選びました。オプション関連のコードがif ~ elseだらけになるのを避けたかったのです。
2. ビルド、デプロイ
TypeScript
tsup
esbuildベースのビルドツールです。速くて設定が簡単なので選びました。
Release Please
ライブラリではありませんが、このGitHub Actionのおかげで手軽にデプロイできたので紹介します。
コミットメッセージの中にconventional commit形式で書かれたものがあれば、pull requestの作成からデプロイまで自動で行ってくれます。特にchange log、release、git tagなども自動で管理してくれるので、開発者はコードだけに集中できます。
ちなみに、Inflabのフロントエンドチームでは、monorepoで構成されたパッケージはchangesetsで、単独のリポジトリで運用しているパッケージはRelease Pleaseで管理しています。
commitlint
上で説明したRelease Pleaseの動作の仕組み上、開発者のミスを防ぐために導入しました。コードの変更がコミットされる前にコミットメッセージを検査し、conventional commitの形式に従っていなければコミットできないようにします。
コミットメッセージの一貫性を保ち、デプロイのためにコードを変更したのに、コミットメッセージの誤りでパッケージがデプロイされないという問題を防ぐのが狙いです。
3. テスト
Jest
3. 実装の詳細
Code generatorは、大きく3つの段階に分けて実行されます。1つ目は前処理の段階です。正しいオプションが渡されたかを検証し、OASのJSONファイルを読み込みます。続いて、ユーザーが望む前処理ロジック(Pre process)を実行して、読み込んだOAS JSONを加工します。
2つ目の段階では、Taskと呼ばれる作業単位が並列に実行されます。どのタスクを実行するかは、そのタスクのオプションが正しく入力されているかどうかによって決まります。
ReactQueryTaskでは、前処理済みのOAS JSONから生成物に使うオブジェクトを作り、適切な名前と対応付けます。Query、url params、response、request bodyなど、必要と思われる要素をすべて抽出し、型とhooksに変換します。
TypeGuardTaskでは、ReactQueryTaskで作られたインターフェースを検証するtype guard関数を作ります。
SchemaValidatorTaskは、OAS JSONで宣言されたすべてのschemaを検証できるstandaloneのAjvインスタンスを生成します。Ajvについては、後ほど改めて詳しく紹介します。
Taskの並列実行が終わったら、最後の段階として、指定された場所へファイルを書き出します。フローチャートで表すと、次のようになります。
1. オプションの入力を受け取る
ユーザーから希望するオプションをYAML形式で受け取るように構成しました。そのオプションは次のとおりです。
loader: fetch | file [required]
schema: string [required]
output-dir: string [required]
react-query: [optional]
output: string [required]
fetcher: string [required]
version: 3 | 4 [optional] default: 3
validator: [optional]
output: string [required]
typeguard: [optional]
output: string [required]
preprocess: [optional]
filterDeprecated: boolean [optional] default: false
extractEnum: boolean [optional] default: false
apiPaths:
- path:
string [required]
exact:
boolean [optional]
tags:
- string [required]
2. 前処理
1. オプションの検証
YAMLファイルの有無の確認、node argumentsの入力チェック、kebab-caseのオプションのcamelCaseへの変換など、I/Oと副作用はConfigProviderが担当するようにしました。
一方、react-query、preprocessなど各オプションのパース、検証、値の設定は、ConfigLoaderを実装したオブジェクトが担います。各オブジェクトがパースしたオプションは、ConfigProviderに渡されます。こうしたのは、オプションごとの変更の影響を互いに切り離すためです。
例えば、react queryを担当するクラスは次のように実装されています。
import Ajv from 'ajv';
import type ConfigLoader from './ConfigLoader';
import type { JSONSchemaType } from 'ajv';
import { isNil } from 'lodash';
import { InvalidConfigException } from '../utils/exception';
type ReactQueryConfigOptions = {
output: string;
fetcher: string;
version?: number;
removeApiEndpointPrefix?: boolean;
};
type YamlReactQueryConfig = {
reactQuery?: ReactQueryConfigOptions;
};
/**
- 1. react-queryオプションのjson validationロジック
*/
const schema: JSONSchemaType<YamlReactQueryConfig> = {
type: 'object',
properties: {
reactQuery: {
type: 'object',
nullable: true,
properties: {
output: { type: 'string' },
fetcher: { type: 'string' },
version: { type: 'number', nullable: true, default: 4, minimum: 3, maximum: 4 },
removeApiEndpointPrefix: { type: 'boolean', nullable: true },
},
required: ['output', 'fetcher'],
},
},
additionalProperties: true,
};
const validate = new Ajv({ useDefaults: true }).compile(schema);
export type ReactQueryConfig = ReactQueryConfigOptions | false;
export default class ReactQueryConfigLoader implements ConfigLoader<ReactQueryConfig> {
/**
- 2. react-queryのオプションの検証
*/
load(optionCandidate: unknown) {
if (!validate(optionCandidate)) {
throw new InvalidConfigException('ReactQueryConfigLoader: not a react query config');
}
/**
- 3. react-queryのオプションを返す
- 戻り値の型はReactQueryConfigOptions | false
*/
if (isNil(optionCandidate.reactQuery)) {
return false;
}
return optionCandidate.reactQuery;
}
}
ReactQueryConfigLoaderでパースしたオプションは、ConfigProviderのパブリックメソッドとして提供されます。
export class ConfigProviderImpl implements ConfigProvider {
private loader: Loader;
private options: Options;
constructor() {
/**
- 1. yamlファイルの読み込み
*/
const yamlCandidate = this.loadConfig();
const reactQueryConfigLoader = new ReactQueryConfigLoader();
/**
- 2. react-queryのオプションのパースを依頼
*/
const reactQueryConfig = reactQueryConfigLoader.load(yamlCandidate);
this.options = {
reactQuery: reactQueryConfig,
};
}
private loadConfig() {
const flags = args.parse(process.argv);
const yamlPath = resolve(process.cwd(), flags.config);
if (typeof yamlPath !== 'string') {
throw new InvalidPathException('ConfigProvider: failed to open yaml config file');
}
const yamlConfig = camelizeKeys(yaml.load(readFileSync(resolve(__dirname, yamlPath), 'utf-8')));
return yamlConfig;
}
getOptions(): Options {
return this.options;
}
/**
- 3. オプションが必要なオブジェクトにオプションを提供
*/
taskOption(): TaskOption {
return pick(this.options, ['reactQuery']);
}
}
この構造のおかげで、ConfigLoaderを実装するだけでConfigProviderから使えるようになります。つまり、オプションはいつでも柔軟に追加・変更できます。
2. OpenAPI specificationファイルを受け取る
当初の目標の中に、「ファイルまたはネットワークリクエストでOAS JSONを直接パースし、望む形の結果をファイルとして生成できること。」がありました。これを実現するために、Loaderを実装したFileLoaderとFetchLoaderを作り、inversifyのfactory関数を使って、オプションに応じて使うべきローダーを選択するようにしました。
// inversify.config.ts
const container = new Container();
/**
- 1. IoCコンテナにオブジェクトを登録
*/
container.bind<Loader>(TYPES.Loader).to(FetchLoader).whenTargetNamed('fetch');
container.bind<Loader>(TYPES.Loader).to(FileLoader).whenTargetNamed('file');
/**
- 2. ファクトリー関数の設定
*/
const loaderFactory = container
.bind<interfaces.Factory<Loader>>(TYPES.LoaderFactory)
.toFactory<Loader, ['file' | 'fetch']>((context) => {
return (named) => {
/**
- 3. どのオブジェクトをどのように読み込むかを明示
*/
return context.container.getNamed<Loader>(TYPES.Loader, named);
};
});
// loader.ts
const selectedLoader = container.getNamed(TYPES.LoaderFactory, options.loader);
このように実装すると、既存のコードを修正せずに別のLoaderを追加できるというメリットがあります。例えば、databaseからOAS JSONを読み込む必要がある場合は、次のように対応できます。
class DatabaseLoader implements Loader {
// ...
}
container.bind<Loader>(TYPES.Loader).to(DatabaseLoader).whenTargetNamed('db');
const databaseLoader = container.getNamed(TYPES.LoaderFactory, 'db');
3. Preprocess pipe
Preprocess pipeは、読み込んだOAS JSONをさまざまな条件で加工する工程です。DeprecatedなAPIを除外したり、特定のAPIだけを選択したり、enumの抽出を選択したりといった処理を担当します。preprocessのオプションはいろいろ選べ、こちらもオプションに対応する前処理のロジックが実行時に決まります。PreprocessPipeクラスが、先ほど紹介したfactory関数でPreprocessを実装したクラスを選んで配列に入れ、OAS JSONを順番に渡しながらロジックを実行します。サンプルコードは次のとおりです。
import { flow } from 'lodash';
class PreprocessPipe {
private readonly preprocessOptions: PreprocessConfig;
private readonly initialPreprocess: Preprocess[];
constructor(
@inject(TYPES.PreprocessFactory)
private readonly preprocessFactory: (name: string) => Preprocess,
) {}
exec(swaggerSpecJSON: SwaggerSpecJSON | undefined): SwaggerSpecJSON | undefined {
/**
- 1. オプションで選択されたpreprocessの実装だけを選んで配列に入れる
*/
const preprocess = this.scheduledPreprocess();
const preprocessorFunctions = preprocess.map((preprocess) => preprocess.exec.bind(preprocess));
/**
- 2. preprocessの実装を順番に実行
*/
const result = flow(preprocessorFunctions)(swaggerSpecJSON);
return result;
}
private scheduledPreprocess() {
if (!this.preprocessOptions) {
return [];
}
/**
- enabled = trueのオプションのオブジェクトだけを読み込む
*/
const selectedPreprocess = Object.entries(this.preprocessOptions)
.filter(([, enabled]) => enabled)
.map(([name]) => this.preprocessFactory(name));
return this.initialPreprocess.concat(selectedPreprocess);
}
}
こちらも、オプションを追加・削除しても既存のロジックに影響しないように実装しています。オプションの読み込みと検証、OAS JSONファイルの読み込み、preprocessの実行という前処理の段階は、使う場所によって実行のされ方が変わりうるため、できるだけ柔軟に設計しました。
3. ReactQueryTask
上で説明したとおり、json-schema-to-typescriptというライブラリで望む生成物を得るためにオブジェクトを作る工程です。ある意味で最も重要な部分なので、責務を細かく分けることに気を配りました。基本的な流れは次のとおりです。
Generator役のオブジェクトは、Parser役のオブジェクトに必要な部分のパースを依頼します。Parserが結果を返すと、それぞれの役割に応じて生成物を文字列として組み立てます。
Object Generatingを担当するオブジェクトは、パースされたデータを使って適切な形のオブジェクトを作ります。インターフェースとして生成される名前を指定して、json-schema-to-typescriptに渡します。例えば次のとおりです。
import { compile } from 'json-schema-to-typescript';
const queryString = queryStringParser.generate(oasJson);
/**
- {
- "title": "V1UserListQueryString",
- "type": "object",
- "properties": {
- "firstName": {
- "type": "string"
- },
- "lastName": {
- "type": "string"
- }
- }
- }
*/
const result = await compile(queryString);
/**
- export interface V1UserListQueryString {
- firstName?: string;
- lastName?: string;
- }
*/
react-queryで使うhooksを作るオブジェクトも、同じくパースされたデータから適切な形のhooksの文字列を作ります。Hooksの場合は、template literalを使えば生成結果を簡単に宣言できます。
// template.ts
export default function useMutationTemplate(name: string, method: string) {
const type = ${name}Response;
const optionType = UseMutationOptions<${name}Response, unknown, ${name}Variables, TContext>;
return `export function use${name}Mutation<TContext>({
url,
options,
requestOptions,
}: {
url: string;
options?: ${optionType};
requestOptions?: RequestOptions;
}) {
const result = useMutation<${type}, unknown, ${name}Variables, TContext>(
(variables) => fetcher.${method}(url, variables, requestOptions),
options,
)
return { ...result, data: result.data?.data };
}`;
}
// ReactMutationGenerator.ts
const result = useMutationTemplate('V1UserList', 'POST');
このようにgenerate layerで作られた結果は、それぞれstring[]の形でReactQueryTaskに戻されます。ここまでの説明は長くてピンとこないかもしれませんが、次のダイアグラムを見ていただければ、思ったよりシンプルな構造であることがおわかりいただけると思います。
4. TypeGuardTask
1. Type guardとは何か
TypeScriptにおけるtype guardとは、複数の型を取りうる変数について、型の推論範囲を絞り込む(narrowing)ための関数のことです。例えば次のとおりです。
// type guard
const isString = (value: unknown): value is string => typeof value === 'string';
const numOrStringDivide = (value: string | number) => {
if (isString(value)) {
return parseInt(value, 10) / 2; // value -> string
}
return value / 2; // value -> number
};
フロントエンドでtype guardが必要になるのは、上の例のようにprimitive typeを区別したい場合もありますが、API responseの違いを見分けて処理を分岐させなければならない場面が出てくるからです。特に、通常の関数だけでなくcomponentの中でも便利です。例えば、API responseの形に応じてレンダリングするcomponentを切り替えたい場合です。こうした場面で、Inflabのフロントエンドチームはtype guardを積極的に使っています。
const isStudent = (value: Student | Teacher): value is Student => {
return isPlainObject(value) && 'grade' in value && typeof value.grade === 'string';
};
export function Profile() {
// Student | Teacher
const { data } = useProfileQuery();
return (
<div>{isStudent(data) ? <StudentProfile data={data} /> : <TeacherProfile data={data} />}</div>
);
}
2. Type guardの落とし穴
もちろん、GraphQLを使えば__typenameというプロパティを使って簡単に区別できます。しかし、そうでない場合は一般的に、API responseの形を必要な分だけパースする方法で型を区別することになります。上の例で言えば、ある値がオブジェクトで、'grade'というstring型のプロパティを持っていれば、'Student'型とみなすことになります。
これはTypeScriptの構造的型システム(structural type system)に起因するもので、よくまとまったドキュメントが多いので、ここではこれ以上説明しません。
本題に戻ると、Inflabのフロントエンドチームではtype guardを積極的に使っていますが、メリットと同じくらいデメリットもはっきりしていました。それは、type guard自体が誤って宣言されている場合、ロジック上の誤りが表に出てこないということです。代表的なのが、API responseが変わったのにtype guardを変更し忘れたケースです。
上の'Student'型で言えば、API responseが変更されて'grade'というプロパティの型が変わったり、プロパティ自体がなくなったりするケースが考えられます。こうなると、コードレビューをしても見つけにくいだけでなく、コンパイル時には何のエラーも発生しないため、ランタイムで問題が発覚することが多いです。
3. Type guardを自動で作る
こうした理由から、type guardもOAS JSONから自動で作られたらどうだろう? というアイデアが浮かびました。同僚と議論した結果、jsonschemaを使ってOASのschemaを検証できる形にし、ファイルとして出力する機能を実装することに決めました。実装全体の流れは次のとおりです。
まず、OAS JSONをJSON schemaの形に変換し、JSON schemaがサポートしていないプロパティを取り除きます。
import { draft7 } from 'json-schema-migrate';
import { unset } from 'lodash';
function convertSwaggerSpecToJSONSchema(swaggerSpec: SwaggerSpecJSON) {
const componentSwaggerSpecs = swaggerSpec.components.schemas;
/**
- 1. OAS -> json schemaの形に変換
*/
draft7(
toJsonSchema(swaggerSpec.components.schemas, {
strictMode: true,
}),
);
/**
- 2. json schemaでサポートされていないプロパティを削除
*/
removeUnSupportProperty(componentSwaggerSpecs);
return componentSwaggerSpecs;
}
function removeUnSupportProperty(componentSwaggerSpecs: Record<string, JSONSchema4>) {
const notSupportedType = ['default', 'deprecated', 'nullable', 'tsEnumNames', 'example'];
traverse(componentSwaggerSpecs, ({ parent, key }) => {
key ??= '';
if (notSupportedType.includes(key)) {
unset(parent, key);
}
});
}
こうして作ったJSON schemaを、template関数を使って文字列にすれば完成です。
const defaultTypeGuardTemplate = (jsonSchemas: Record<string, JSONSchema4>) => {
return [
importTemplate(Object.keys(jsonSchemas)).trim(),
'const validator = new Validator();',
Object.entries(jsonSchemas)
.map(([key, schema]) => typeGuardTemplate(key, schema))
.join('')
.trim(),
].join('\n\n');
};
const importTemplate = (keys: string[]) => {
return `
import { Validator } from 'jsonschema';
import type { ${keys.join(', ')} } from './schema';
`;
};
const typeGuardTemplate = (type: string, jsonSchema: JSONSchema4) => {
const stringifyJsonScheme = JSON.stringify(jsonSchema);
return `
export const ${is${type}} = (value: unknown): value is ${type} => {
return validator.validate(value, ${stringifyJsonScheme}).valid;
}
`;
};
const schema = convertSwaggerSpecToJSONSchema(oasJson);
const result = defaultTypeGuardTemplate(schema);
まだ完璧に動作するわけではありませんが、問題なく使えるようドッグフーディングを続けています。
5. SchemaValidatorTask
上でもお伝えしたとおり、Elixirのdoc testの考え方を取り入れ、バックエンドエンジニアが自分の宣言したOASと実際のresponseが一致しているかを、integration testの段階で検証できる機能を提供しようとしました。ライブラリにはAjvを選びました。バックエンド側で別途ライブラリをインストールしなくても、standaloneモードで動作するからです。
SchemaValidatorTaskの仕組みは、前の2つのタスクに比べてとてもシンプルです。OASをJSON schemaに変換し、standaloneモードを適用した生成物を返せば完了です。
import toJsonSchema from '@openapi-contrib/openapi-schema-to-json-schema';
import Ajv from 'ajv';
import standaloneCode from 'ajv/dist/standalone';
const schema = toJsonSchema(oasJson);
const ajv = new Ajv({
schemas,
code: {
source: true,
optimize: true,
},
});
const result = standaloneCode(ajv);
6. 生成物の出力
各タスクでは、受け取ったstring配列から一定の基準で重複を取り除きます。ここまで問題がなければ、オプションで指定されたパスに結果を出力します。
4. 生成物の形
次のようなOAS JSONを受け取ったと仮定すると、各タスクの実行結果は以下のとおりです。
OAS.json{
"openapi": "3.0.0",
"paths": {
"/api/v1/category": {
"get": {
"operationId": "CategoryB2cController_findAll",
"summary": "すべてのカテゴリー一覧",
"description": "すべてのカテゴリー一覧を取得する。",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"properties": {
"statusCode": {
"type": "string"
},
"message": {
"type": "string"
},
"data": {
"$ref": "#/components/schemas/CategoryAllResponse"
}
}
}
}
}
}
},
"tags": ["カテゴリーenum API"]
}
}
},
"components": {
"schemas": {
"CodeName": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"name": {
"type": "string"
}
},
"required": ["code", "name"]
},
"JobGroupCodeName": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"name": {
"type": "string"
},
"jobs": {
"type": "array",
"items": {
"$ref": "#/components/schemas/CodeName"
}
}
},
"required": ["code", "name", "jobs"]
},
"CategoryAllResponse": {
"type": "object",
"properties": {
"jobGroup": {
"type": "array",
"items": {
"$ref": "#/components/schemas/JobGroupCodeName"
}
},
"jobSeekerStatus": {
"type": "array",
"items": {
"$ref": "#/components/schemas/CodeName"
}
}
},
"required": ["jobSeekerStatus"]
}
}
}
}
ReactQueryTaskimport { useQuery, useMutation } from '@tanstack/react-query';
import type { UseQueryOptions, UseMutationOptions, QueryKey } from '@tanstack/react-query';
import fetcher from './Fetcher';
import type { RequestOptions } from './Fetcher';
export type Maybe<T> = T | null;
export type OneOf<T> = T extends unknown[]
? T[number]
: T extends readonly unknown[]
? T[number]
: T;
export type Exact<T extends { [key: string]: unknown }> = {
[K in keyof T]-?: Exclude<T[K], undefined>;
};
export type Values<T extends Record<string, unknown>> = T extends Record<string, infer U>
? U
: T extends { [key: string]: infer U }
? U
: never;
export const enum ResponseStatus {
OK = 'OK',
SERVER_ERROR = 'SERVER_ERROR',
DUPLICATE = 'DUPLICATE',
NOT_FOUND = 'NOT_FOUND',
BAD_REQUEST = 'BAD_REQUEST',
BAD_PARAMETER = 'BAD_PARAMETER',
CONFLICT = 'CONFLICT',
UNAUTHORIZED = 'UNAUTHORIZED',
FORBIDDEN = 'FORBIDDEN',
PAYLOAD_TOO_LARGE = 'PAYLOAD_TOO_LARGE',
TOO_MANY_REQUESTS = 'TOO_MANY_REQUESTS',
}
export type Success<T = unknown> = {
statusCode: ResponseStatus.OK;
message: '';
data: T;
};
export type Error = {
statusCode: Omit<keyof typeof ResponseStatus, 'OK'>;
message: string;
data: Maybe<''>;
};
/**
- @typeParam T - Response data type
*/
export type ApiResponse<T = unknown> = Success<T>;
export const API_ENDPOINT = {
V1_CATEGORY: '/api/v1/category',
} as const;
export const fetchV1Category = async (url: string, requestOptions?: RequestOptions) =>
fetcher.get<V1CategoryResponse>(url, requestOptions);
export function useV1CategoryQuery({
queryKey,
url,
options,
requestOptions,
}: {
queryKey: QueryKey;
url: string;
options?: Omit<
UseQueryOptions<V1CategoryResponse, unknown>,
'queryKey' | 'queryFn' | 'initialData'
> & {
initialData?: () => undefined;
};
requestOptions?: RequestOptions;
}) {
const result = useQuery<V1CategoryResponse, unknown>(
queryKey,
() => fetchV1Category(url, requestOptions),
options,
);
return { ...result, data: result.data?.data };
}
/**
- created by ResponseSchemaGenerator
*/
export interface V1Category {
data: Maybe<CategoryAllResponse>;
}
export interface CategoryAllResponse {
jobGroup: Maybe<JobGroupCodeName[]>;
jobSeekerStatus: CodeName[];
}
export interface JobGroupCodeName {
code: string;
name: string;
jobs: CodeName[];
}
export interface CodeName {
code: string;
name: string;
}
export type V1CategoryResponse = ApiResponse<V1Category['data']>;
TypeGuardTaskimport { Validator } from 'jsonschema';
import type { CodeName, JobGroupCodeName, CategoryAllResponse } from './schema';
const validator = new Validator();
export const isCodeName = (value: unknown): value is CodeName => {
return validator.validate(value, {
type: 'object',
properties: { code: { type: 'string' }, name: { type: 'string' } },
required: ['code', 'name'],
}).valid;
};
export const isJobGroupCodeName = (value: unknown): value is JobGroupCodeName => {
return validator.validate(value, {
type: 'object',
properties: {
code: { type: 'string' },
name: { type: 'string' },
jobs: {
type: 'array',
items: {
type: 'object',
properties: { code: { type: 'string' }, name: { type: 'string' } },
required: ['code', 'name'],
},
},
},
required: ['code', 'name', 'jobs'],
}).valid;
};
export const isCategoryAllResponse = (value: unknown): value is CategoryAllResponse => {
return validator.validate(value, {
type: 'object',
properties: {
jobGroup: {
type: 'array',
items: {
type: 'object',
properties: {
code: { type: 'string' },
name: { type: 'string' },
jobs: {
type: 'array',
items: {
type: 'object',
properties: { code: { type: 'string' }, name: { type: 'string' } },
required: ['code', 'name'],
},
},
},
required: ['code', 'name', 'jobs'],
},
},
jobSeekerStatus: {
type: 'array',
items: {
type: 'object',
properties: { code: { type: 'string' }, name: { type: 'string' } },
required: ['code', 'name'],
},
},
},
required: ['jobSeekerStatus'],
}).valid;
};
SchemaValidatorTaskの結果は1つの複雑なファイルになるため、ここでは紹介を割愛します。
こうして作られた生成物は、フロントエンドのコードで次のように使えます。
import { useV1CategoryQuery, API_ENDPOINT } from './schema';
import { isJobGroupCodeName } from './guard';
const retrieveJobGroupCodeNameJobs = (value: unknown) => {
if (isJobGroupCodeName(value)) {
return value.jobs;
}
return [];
};
export function A() {
const { data } = useV1CategoryQuery({
url: API_ENDPOINT.V1_CATEGORY,
queryKey: [API_ENDPOINT.V1_CATEGORY],
});
return <div>{data.jobSeekerStatus.map((status) => status.name)}</div>;
}
バックエンドのテストコードでは、schema validatorを次のように使えます。
import validator from './validator';
describe('categoryAllResponse', () => {
it('categoryAllResponseのOASと実際の結果は一致しなければならない。', async () => {
const response = await controller.categoryAllResponse();
const result = validator.categoryAllResponse(result);
expect(result).toBe(true);
});
});
5. まとめ
最初にお伝えしたとおり、一見すると工程は複雑に見えますが、まったくそんなことはありません。むしろ実装中に悩んだのは、責務をどう適切に分担するかと、SonarQubeのduplicate line code smellを避けるためにコードの重複をどう抑えるかでした。そのため、コード全体を通して次のような選択をしました。
各オブジェクトの責務を細かく分離するよう努めました。
各オブジェクトの横断的関心事はServiceというlayerに分け、必要なオブジェクトが必要な分だけ参照するように実装しました。
実行時に動くロジックを切り替える必要がある場合は、Strategyパターンを積極的に使いました。
同じロジックで小さな部分だけを変える必要がある場合は、Template Methodパターンを使いましたが、継承が1段階を超えないように実装しました。
実装した構造を簡単に表すと、次のようになります。
実装の紹介は以上です。
次回の記事では、code generatorを導入してから働き方がどのように変わったのかをご紹介します。
Mentions
Code generator開発記(Part 2) ! この記事は2023年7月3日に執筆されたものです。 こんにちは。Inflabのフロントエンドエンジニア、Lucasです。 前回の記事では、code generatorの必要性と、自前で実装するに至るまでの悩みを共有しました。今回は、code generatorをはじめ、schema validator、type guardをどのように実装したのかを簡単にご紹介したいと思います。 最初にお伝えしておくと、これはとてもシンプルなアイデアで、実装に複雑な技術は必要ありません。その前提で読み進めていただければと思います。 1. 目標と必要な機能 まず、前回の記事で立てた目標について改めて考えてみます。私はcode generatorを実装する前に、次のような目標を立てました。 ファイルまたはネットワークリクエストでOpenAPI specification JSONを直接パースし、望む形の結果をファイルとして生成できること。 フロントエンドエンジニアが、APIのschemaの変化にできるだけ手間なく対応できること。 フロントエンドエンジニアが、生成物として出力したいAPIを選択できること。 APIで宣言したresponseをJSON validatorで検証できるtype guardが自動生成されること。 バックエンドエンジニアが、フロントエンドの手間を気にせずAPIを作れること。 バックエンドエンジニアが宣言したSwaggerと実際のAPI responseの形が一致しているかを、integration testで検証できること。 特に最後の目標は、Elixirのdoc testの概念を借りたものです。昨年取り組んだ個人プロジェクトでテストを書いた際の感触が良く、GraphQLがサポートしているようなschemaの整合性検証も、近い形なら実現できるのではないかと期待していました。特に私たちのバックエンドチームはテストを書くことを非常に重視しているので、OpenAPI specificationと実際のAPIの結果をテストする手軽な方法を提供できるだろうと考えました。 以下、OpenAPI specification JSONはOASと略します。 2. 使用したライブラリ まず、実装に使ったライブラリを紹介します。おそらくほとんどの方がご存じだと思います。 1. 実装 inversify 機能が増えるほど、コードベースは必然的に大きく複雑になるだろうと考えました。また、複数のオプションをサポートする予定だったので、実行時に動く実装も変わりうると見ていました。 そのため機能間の依存関係が複雑になりそうだと考え、悩んだ末に依存性注入(DI)のためのIoCコンテナを探すことにしました。私はinversifyを選びましたが、もう少し軽量に使いたいのであれば、typediという選択肢もあります。 json-schema-to-typescript 事実上、code generatorの中核となるライブラリです。Code generatorのほぼすべての実装は、このライブラリから望む結果を得るためのオブジェクトを作る作業だと言っても過言ではありません。例えば、次のようなJSON schemaがある場合、 { "title": "Example Schema", "type": "object", "properties": { "firstName": { "type": "string" }, "lastName": { "type": "string" }, "age": { "description": "Age in years", "type": "integer", "minimum": 0 }, "hairColor": { "enum": ["black", "brown", "blue"], "type": "string" } }, "additionalProperties": false, "required": ["firstName", "lastName"] } 次のように型を生成します。 export interface ExampleSchema { firstName: string; lastName: string; /** - Age in years */ age?: number; hairColor?: 'black' | 'brown' | 'blue'; } lodash object-traversal js-yaml オプションをYAML形式で受け取るつもりでインストールしました。今思えば、普通にJavaScriptで受け取っても問題なかったと思います。 jsonschema Code generatorが受け付けられるオプションを検証するために選びました。オプション関連のコードがif ~ elseだらけになるのを避けたかったのです。 2. ビルド、デプロイ TypeScript tsup esbuildベースのビルドツールです。速くて設定が簡単なので選びました。 Release Please ライブラリではありませんが、このGitHub Actionのおかげで手軽にデプロイできたので紹介します。 コミットメッセージの中にconventional commit形式で書かれたものがあれば、pull requestの作成からデプロイまで自動で行ってくれます。特にchange log、release、git tagなども自動で管理してくれるので、開発者はコードだけに集中できます。 ちなみに、Inflabのフロントエンドチームでは、monorepoで構成されたパッケージはchangesetsで、単独のリポジトリで運用しているパッケージはRelease Pleaseで管理しています。 commitlint 上で説明したRelease Pleaseの動作の仕組み上、開発者のミスを防ぐために導入しました。コードの変更がコミットされる前にコミットメッセージを検査し、conventional commitの形式に従っていなければコミットできないようにします。 コミットメッセージの一貫性を保ち、デプロイのためにコードを変更したのに、コミットメッセージの誤りでパッケージがデプロイされないという問題を防ぐのが狙いです。 3. テスト Jest 3. 実装の詳細 Code generatorは、大きく3つの段階に分けて実行されます。1つ目は前処理の段階です。正しいオプションが渡されたかを検証し、OASのJSONファイルを読み込みます。続いて、ユーザーが望む前処理ロジック(Pre process)を実行して、読み込んだOAS JSONを加工します。 2つ目の段階では、Taskと呼ばれる作業単位が並列に実行されます。どのタスクを実行するかは、そのタスクのオプションが正しく入力されているかどうかによって決まります。 ReactQueryTaskでは、前処理済みのOAS JSONから生成物に使うオブジェクトを作り、適切な名前と対応付けます。Query、url params、response、request bodyなど、必要と思われる要素をすべて抽出し、型とhooksに変換します。 TypeGuardTaskでは、ReactQueryTaskで作られたインターフェースを検証するtype guard関数を作ります。 SchemaValidatorTaskは、OAS JSONで宣言されたすべてのschemaを検証できるstandaloneのAjvインスタンスを生成します。Ajvについては、後ほど改めて詳しく紹介します。 Taskの並列実行が終わったら、最後の段階として、指定された場所へファイルを書き出します。フローチャートで表すと、次のようになります。 1. オプションの入力を受け取る ユーザーから希望するオプションをYAML形式で受け取るように構成しました。そのオプションは次のとおりです。 loader: fetch | file [required] schema: string [required] output-dir: string [required] react-query: [optional] output: string [required] fetcher: string [required] version: 3 | 4 [optional] default: 3 validator: [optional] output: string [required] typeguard: [optional] output: string [required] preprocess: [optional] filterDeprecated: boolean [optional] default: false extractEnum: boolean [optional] default: false apiPaths: - path: string [required] exact: boolean [optional] tags: - string [required] 2. 前処理 1. オプションの検証 YAMLファイルの有無の確認、node argumentsの入力チェック、kebab-caseのオプションのcamelCaseへの変換など、I/Oと副作用はConfigProviderが担当するようにしました。 一方、react-query、preprocessなど各オプションのパース、検証、値の設定は、ConfigLoaderを実装したオブジェクトが担います。各オブジェクトがパースしたオプションは、ConfigProviderに渡されます。こうしたのは、オプションごとの変更の影響を互いに切り離すためです。 例えば、react queryを担当するクラスは次のように実装されています。 import Ajv from 'ajv'; import type ConfigLoader from './ConfigLoader'; import type { JSONSchemaType } from 'ajv'; import { isNil } from 'lodash'; import { InvalidConfigException } from '../utils/exception'; type ReactQueryConfigOptions = { output: string; fetcher: string; version?: number; removeApiEndpointPrefix?: boolean; }; type YamlReactQueryConfig = { reactQuery?: ReactQueryConfigOptions; }; /** - 1. react-queryオプションのjson validationロジック */ const schema: JSONSchemaType<YamlReactQueryConfig> = { type: 'object', properties: { reactQuery: { type: 'object', nullable: true, properties: { output: { type: 'string' }, fetcher: { type: 'string' }, version: { type: 'number', nullable: true, default: 4, minimum: 3, maximum: 4 }, removeApiEndpointPrefix: { type: 'boolean', nullable: true }, }, required: ['output', 'fetcher'], }, }, additionalProperties: true, }; const validate = new Ajv({ useDefaults: true }).compile(schema); export type ReactQueryConfig = ReactQueryConfigOptions | false; export default class ReactQueryConfigLoader implements ConfigLoader<ReactQueryConfig> { /** - 2. react-queryのオプションの検証 */ load(optionCandidate: unknown) { if (!validate(optionCandidate)) { throw new InvalidConfigException('ReactQueryConfigLoader: not a react query config'); } /** - 3. react-queryのオプションを返す - 戻り値の型はReactQueryConfigOptions | false */ if (isNil(optionCandidate.reactQuery)) { return false; } return optionCandidate.reactQuery; } } ReactQueryConfigLoaderでパースしたオプションは、ConfigProviderのパブリックメソッドとして提供されます。 export class ConfigProviderImpl implements ConfigProvider { private loader: Loader; private options: Options; constructor() { /** - 1. yamlファイルの読み込み */ const yamlCandidate = this.loadConfig(); const reactQueryConfigLoader = new ReactQueryConfigLoader(); /** - 2. react-queryのオプションのパースを依頼 */ const reactQueryConfig = reactQueryConfigLoader.load(yamlCandidate); this.options = { reactQuery: reactQueryConfig, }; } private loadConfig() { const flags = args.parse(process.argv); const yamlPath = resolve(process.cwd(), flags.config); if (typeof yamlPath !== 'string') { throw new InvalidPathException('ConfigProvider: failed to open yaml config file'); } const yamlConfig = camelizeKeys(yaml.load(readFileSync(resolve(__dirname, yamlPath), 'utf-8'))); return yamlConfig; } getOptions(): Options { return this.options; } /** - 3. オプションが必要なオブジェクトにオプションを提供 */ taskOption(): TaskOption { return pick(this.options, ['reactQuery']); } } この構造のおかげで、ConfigLoaderを実装するだけでConfigProviderから使えるようになります。つまり、オプションはいつでも柔軟に追加・変更できます。 2. OpenAPI specificationファイルを受け取る 当初の目標の中に、「ファイルまたはネットワークリクエストでOAS JSONを直接パースし、望む形の結果をファイルとして生成できること。」がありました。これを実現するために、Loaderを実装したFileLoaderとFetchLoaderを作り、inversifyのfactory関数を使って、オプションに応じて使うべきローダーを選択するようにしました。 // inversify.config.ts const container = new Container(); /** - 1. IoCコンテナにオブジェクトを登録 */ container.bind<Loader>(TYPES.Loader).to(FetchLoader).whenTargetNamed('fetch'); container.bind<Loader>(TYPES.Loader).to(FileLoader).whenTargetNamed('file'); /** - 2. ファクトリー関数の設定 */ const loaderFactory = container .bind<interfaces.Factory<Loader>>(TYPES.LoaderFactory) .toFactory<Loader, ['file' | 'fetch']>((context) => { return (named) => { /** - 3. どのオブジェクトをどのように読み込むかを明示 */ return context.container.getNamed<Loader>(TYPES.Loader, named); }; }); // loader.ts const selectedLoader = container.getNamed(TYPES.LoaderFactory, options.loader); このように実装すると、既存のコードを修正せずに別のLoaderを追加できるというメリットがあります。例えば、databaseからOAS JSONを読み込む必要がある場合は、次のように対応できます。 class DatabaseLoader implements Loader { // ... } container.bind<Loader>(TYPES.Loader).to(DatabaseLoader).whenTargetNamed('db'); const databaseLoader = container.getNamed(TYPES.LoaderFactory, 'db'); 3. Preprocess pipe Preprocess pipeは、読み込んだOAS JSONをさまざまな条件で加工する工程です。DeprecatedなAPIを除外したり、特定のAPIだけを選択したり、enumの抽出を選択したりといった処理を担当します。preprocessのオプションはいろいろ選べ、こちらもオプションに対応する前処理のロジックが実行時に決まります。PreprocessPipeクラスが、先ほど紹介したfactory関数でPreprocessを実装したクラスを選んで配列に入れ、OAS JSONを順番に渡しながらロジックを実行します。サンプルコードは次のとおりです。 import { flow } from 'lodash'; class PreprocessPipe { private readonly preprocessOptions: PreprocessConfig; private readonly initialPreprocess: Preprocess[]; constructor( @inject(TYPES.PreprocessFactory) private readonly preprocessFactory: (name: string) => Preprocess, ) {} exec(swaggerSpecJSON: SwaggerSpecJSON | undefined): SwaggerSpecJSON | undefined { /** - 1. オプションで選択されたpreprocessの実装だけを選んで配列に入れる */ const preprocess = this.scheduledPreprocess(); const preprocessorFunctions = preprocess.map((preprocess) => preprocess.exec.bind(preprocess)); /** - 2. preprocessの実装を順番に実行 */ const result = flow(preprocessorFunctions)(swaggerSpecJSON); return result; } private scheduledPreprocess() { if (!this.preprocessOptions) { return []; } /** - enabled = trueのオプションのオブジェクトだけを読み込む */ const selectedPreprocess = Object.entries(this.preprocessOptions) .filter(([, enabled]) => enabled) .map(([name]) => this.preprocessFactory(name)); return this.initialPreprocess.concat(selectedPreprocess); } } こちらも、オプションを追加・削除しても既存のロジックに影響しないように実装しています。オプションの読み込みと検証、OAS JSONファイルの読み込み、preprocessの実行という前処理の段階は、使う場所によって実行のされ方が変わりうるため、できるだけ柔軟に設計しました。 3. ReactQueryTask 上で説明したとおり、json-schema-to-typescriptというライブラリで望む生成物を得るためにオブジェクトを作る工程です。ある意味で最も重要な部分なので、責務を細かく分けることに気を配りました。基本的な流れは次のとおりです。 Generator役のオブジェクトは、Parser役のオブジェクトに必要な部分のパースを依頼します。Parserが結果を返すと、それぞれの役割に応じて生成物を文字列として組み立てます。 Object Generatingを担当するオブジェクトは、パースされたデータを使って適切な形のオブジェクトを作ります。インターフェースとして生成される名前を指定して、json-schema-to-typescriptに渡します。例えば次のとおりです。 import { compile } from 'json-schema-to-typescript'; const queryString = queryStringParser.generate(oasJson); /** - { - "title": "V1UserListQueryString", - "type": "object", - "properties": { - "firstName": { - "type": "string" - }, - "lastName": { - "type": "string" - } - } - } */ const result = await compile(queryString); /** - export interface V1UserListQueryString { - firstName?: string; - lastName?: string; - } */ react-queryで使うhooksを作るオブジェクトも、同じくパースされたデータから適切な形のhooksの文字列を作ります。Hooksの場合は、template literalを使えば生成結果を簡単に宣言できます。 // template.ts export default function useMutationTemplate(name: string, method: string) { const type = ${name}Response; const optionType = UseMutationOptions<${name}Response, unknown, ${name}Variables, TContext>; return `export function use${name}Mutation<TContext>({ url, options, requestOptions, }: { url: string; options?: ${optionType}; requestOptions?: RequestOptions; }) { const result = useMutation<${type}, unknown, ${name}Variables, TContext>( (variables) => fetcher.${method}(url, variables, requestOptions), options, ) return { ...result, data: result.data?.data }; }`; } // ReactMutationGenerator.ts const result = useMutationTemplate('V1UserList', 'POST'); このようにgenerate layerで作られた結果は、それぞれstring[]の形でReactQueryTaskに戻されます。ここまでの説明は長くてピンとこないかもしれませんが、次のダイアグラムを見ていただければ、思ったよりシンプルな構造であることがおわかりいただけると思います。 4. TypeGuardTask 1. Type guardとは何か TypeScriptにおけるtype guardとは、複数の型を取りうる変数について、型の推論範囲を絞り込む(narrowing)ための関数のことです。例えば次のとおりです。 // type guard const isString = (value: unknown): value is string => typeof value === 'string'; const numOrStringDivide = (value: string | number) => { if (isString(value)) { return parseInt(value, 10) / 2; // value -> string } return value / 2; // value -> number }; フロントエンドでtype guardが必要になるのは、上の例のようにprimitive typeを区別したい場合もありますが、API responseの違いを見分けて処理を分岐させなければならない場面が出てくるからです。特に、通常の関数だけでなくcomponentの中でも便利です。例えば、API responseの形に応じてレンダリングするcomponentを切り替えたい場合です。こうした場面で、Inflabのフロントエンドチームはtype guardを積極的に使っています。 const isStudent = (value: Student | Teacher): value is Student => { return isPlainObject(value) && 'grade' in value && typeof value.grade === 'string'; }; export function Profile() { // Student | Teacher const { data } = useProfileQuery(); return ( <div>{isStudent(data) ? <StudentProfile data={data} /> : <TeacherProfile data={data} />}</div> ); } 2. Type guardの落とし穴 もちろん、GraphQLを使えば__typenameというプロパティを使って簡単に区別できます。しかし、そうでない場合は一般的に、API responseの形を必要な分だけパースする方法で型を区別することになります。上の例で言えば、ある値がオブジェクトで、'grade'というstring型のプロパティを持っていれば、'Student'型とみなすことになります。 これはTypeScriptの構造的型システム(structural type system)に起因するもので、よくまとまったドキュメントが多いので、ここではこれ以上説明しません。 本題に戻ると、Inflabのフロントエンドチームではtype guardを積極的に使っていますが、メリットと同じくらいデメリットもはっきりしていました。それは、type guard自体が誤って宣言されている場合、ロジック上の誤りが表に出てこないということです。代表的なのが、API responseが変わったのにtype guardを変更し忘れたケースです。 上の'Student'型で言えば、API responseが変更されて'grade'というプロパティの型が変わったり、プロパティ自体がなくなったりするケースが考えられます。こうなると、コードレビューをしても見つけにくいだけでなく、コンパイル時には何のエラーも発生しないため、ランタイムで問題が発覚することが多いです。 3. Type guardを自動で作る こうした理由から、type guardもOAS JSONから自動で作られたらどうだろう? というアイデアが浮かびました。同僚と議論した結果、jsonschemaを使ってOASのschemaを検証できる形にし、ファイルとして出力する機能を実装することに決めました。実装全体の流れは次のとおりです。 まず、OAS JSONをJSON schemaの形に変換し、JSON schemaがサポートしていないプロパティを取り除きます。 import { draft7 } from 'json-schema-migrate'; import { unset } from 'lodash'; function convertSwaggerSpecToJSONSchema(swaggerSpec: SwaggerSpecJSON) { const componentSwaggerSpecs = swaggerSpec.components.schemas; /** - 1. OAS -> json schemaの形に変換 */ draft7( toJsonSchema(swaggerSpec.components.schemas, { strictMode: true, }), ); /** - 2. json schemaでサポートされていないプロパティを削除 */ removeUnSupportProperty(componentSwaggerSpecs); return componentSwaggerSpecs; } function removeUnSupportProperty(componentSwaggerSpecs: Record<string, JSONSchema4>) { const notSupportedType = ['default', 'deprecated', 'nullable', 'tsEnumNames', 'example']; traverse(componentSwaggerSpecs, ({ parent, key }) => { key ??= ''; if (notSupportedType.includes(key)) { unset(parent, key); } }); } こうして作ったJSON schemaを、template関数を使って文字列にすれば完成です。 const defaultTypeGuardTemplate = (jsonSchemas: Record<string, JSONSchema4>) => { return [ importTemplate(Object.keys(jsonSchemas)).trim(), 'const validator = new Validator();', Object.entries(jsonSchemas) .map(([key, schema]) => typeGuardTemplate(key, schema)) .join('') .trim(), ].join('\n\n'); }; const importTemplate = (keys: string[]) => { return ` import { Validator } from 'jsonschema'; import type { ${keys.join(', ')} } from './schema'; `; }; const typeGuardTemplate = (type: string, jsonSchema: JSONSchema4) => { const stringifyJsonScheme = JSON.stringify(jsonSchema); return ` export const ${is${type}} = (value: unknown): value is ${type} => { return validator.validate(value, ${stringifyJsonScheme}).valid; } `; }; const schema = convertSwaggerSpecToJSONSchema(oasJson); const result = defaultTypeGuardTemplate(schema); まだ完璧に動作するわけではありませんが、問題なく使えるようドッグフーディングを続けています。 5. SchemaValidatorTask 上でもお伝えしたとおり、Elixirのdoc testの考え方を取り入れ、バックエンドエンジニアが自分の宣言したOASと実際のresponseが一致しているかを、integration testの段階で検証できる機能を提供しようとしました。ライブラリにはAjvを選びました。バックエンド側で別途ライブラリをインストールしなくても、standaloneモードで動作するからです。 SchemaValidatorTaskの仕組みは、前の2つのタスクに比べてとてもシンプルです。OASをJSON schemaに変換し、standaloneモードを適用した生成物を返せば完了です。 import toJsonSchema from '@openapi-contrib/openapi-schema-to-json-schema'; import Ajv from 'ajv'; import standaloneCode from 'ajv/dist/standalone'; const schema = toJsonSchema(oasJson); const ajv = new Ajv({ schemas, code: { source: true, optimize: true, }, }); const result = standaloneCode(ajv); 6. 生成物の出力 各タスクでは、受け取ったstring配列から一定の基準で重複を取り除きます。ここまで問題がなければ、オプションで指定されたパスに結果を出力します。 4. 生成物の形 次のようなOAS JSONを受け取ったと仮定すると、各タスクの実行結果は以下のとおりです。 OAS.json{ "openapi": "3.0.0", "paths": { "/api/v1/category": { "get": { "operationId": "CategoryB2cController_findAll", "summary": "すべてのカテゴリー一覧", "description": "すべてのカテゴリー一覧を取得する。", "parameters": [], "responses": { "200": { "description": "", "content": { "application/json": { "schema": { "properties": { "statusCode": { "type": "string" }, "message": { "type": "string" }, "data": { "$ref": "#/components/schemas/CategoryAllResponse" } } } } } } }, "tags": ["カテゴリーenum API"] } } }, "components": { "schemas": { "CodeName": { "type": "object", "properties": { "code": { "type": "string" }, "name": { "type": "string" } }, "required": ["code", "name"] }, "JobGroupCodeName": { "type": "object", "properties": { "code": { "type": "string" }, "name": { "type": "string" }, "jobs": { "type": "array", "items": { "$ref": "#/components/schemas/CodeName" } } }, "required": ["code", "name", "jobs"] }, "CategoryAllResponse": { "type": "object", "properties": { "jobGroup": { "type": "array", "items": { "$ref": "#/components/schemas/JobGroupCodeName" } }, "jobSeekerStatus": { "type": "array", "items": { "$ref": "#/components/schemas/CodeName" } } }, "required": ["jobSeekerStatus"] } } } } ReactQueryTaskimport { useQuery, useMutation } from '@tanstack/react-query'; import type { UseQueryOptions, UseMutationOptions, QueryKey } from '@tanstack/react-query'; import fetcher from './Fetcher'; import type { RequestOptions } from './Fetcher'; export type Maybe<T> = T | null; export type OneOf<T> = T extends unknown[] ? T[number] : T extends readonly unknown[] ? T[number] : T; export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]-?: Exclude<T[K], undefined>; }; export type Values<T extends Record<string, unknown>> = T extends Record<string, infer U> ? U : T extends { [key: string]: infer U } ? U : never; export const enum ResponseStatus { OK = 'OK', SERVER_ERROR = 'SERVER_ERROR', DUPLICATE = 'DUPLICATE', NOT_FOUND = 'NOT_FOUND', BAD_REQUEST = 'BAD_REQUEST', BAD_PARAMETER = 'BAD_PARAMETER', CONFLICT = 'CONFLICT', UNAUTHORIZED = 'UNAUTHORIZED', FORBIDDEN = 'FORBIDDEN', PAYLOAD_TOO_LARGE = 'PAYLOAD_TOO_LARGE', TOO_MANY_REQUESTS = 'TOO_MANY_REQUESTS', } export type Success<T = unknown> = { statusCode: ResponseStatus.OK; message: ''; data: T; }; export type Error = { statusCode: Omit<keyof typeof ResponseStatus, 'OK'>; message: string; data: Maybe<''>; }; /** - @typeParam T - Response data type */ export type ApiResponse<T = unknown> = Success<T>; export const API_ENDPOINT = { V1_CATEGORY: '/api/v1/category', } as const; export const fetchV1Category = async (url: string, requestOptions?: RequestOptions) => fetcher.get<V1CategoryResponse>(url, requestOptions); export function useV1CategoryQuery({ queryKey, url, options, requestOptions, }: { queryKey: QueryKey; url: string; options?: Omit< UseQueryOptions<V1CategoryResponse, unknown>, 'queryKey' | 'queryFn' | 'initialData' > & { initialData?: () => undefined; }; requestOptions?: RequestOptions; }) { const result = useQuery<V1CategoryResponse, unknown>( queryKey, () => fetchV1Category(url, requestOptions), options, ); return { ...result, data: result.data?.data }; } /** - created by ResponseSchemaGenerator */ export interface V1Category { data: Maybe<CategoryAllResponse>; } export interface CategoryAllResponse { jobGroup: Maybe<JobGroupCodeName[]>; jobSeekerStatus: CodeName[]; } export interface JobGroupCodeName { code: string; name: string; jobs: CodeName[]; } export interface CodeName { code: string; name: string; } export type V1CategoryResponse = ApiResponse<V1Category['data']>; TypeGuardTaskimport { Validator } from 'jsonschema'; import type { CodeName, JobGroupCodeName, CategoryAllResponse } from './schema'; const validator = new Validator(); export const isCodeName = (value: unknown): value is CodeName => { return validator.validate(value, { type: 'object', properties: { code: { type: 'string' }, name: { type: 'string' } }, required: ['code', 'name'], }).valid; }; export const isJobGroupCodeName = (value: unknown): value is JobGroupCodeName => { return validator.validate(value, { type: 'object', properties: { code: { type: 'string' }, name: { type: 'string' }, jobs: { type: 'array', items: { type: 'object', properties: { code: { type: 'string' }, name: { type: 'string' } }, required: ['code', 'name'], }, }, }, required: ['code', 'name', 'jobs'], }).valid; }; export const isCategoryAllResponse = (value: unknown): value is CategoryAllResponse => { return validator.validate(value, { type: 'object', properties: { jobGroup: { type: 'array', items: { type: 'object', properties: { code: { type: 'string' }, name: { type: 'string' }, jobs: { type: 'array', items: { type: 'object', properties: { code: { type: 'string' }, name: { type: 'string' } }, required: ['code', 'name'], }, }, }, required: ['code', 'name', 'jobs'], }, }, jobSeekerStatus: { type: 'array', items: { type: 'object', properties: { code: { type: 'string' }, name: { type: 'string' } }, required: ['code', 'name'], }, }, }, required: ['jobSeekerStatus'], }).valid; }; SchemaValidatorTaskの結果は1つの複雑なファイルになるため、ここでは紹介を割愛します。 こうして作られた生成物は、フロントエンドのコードで次のように使えます。 import { useV1CategoryQuery, API_ENDPOINT } from './schema'; import { isJobGroupCodeName } from './guard'; const retrieveJobGroupCodeNameJobs = (value: unknown) => { if (isJobGroupCodeName(value)) { return value.jobs; } return []; }; export function A() { const { data } = useV1CategoryQuery({ url: API_ENDPOINT.V1_CATEGORY, queryKey: [API_ENDPOINT.V1_CATEGORY], }); return <div>{data.jobSeekerStatus.map((status) => status.name)}</div>; } バックエンドのテストコードでは、schema validatorを次のように使えます。 import validator from './validator'; describe('categoryAllResponse', () => { it('categoryAllResponseのOASと実際の結果は一致しなければならない。', async () => { const response = await controller.categoryAllResponse(); const result = validator.categoryAllResponse(result); expect(result).toBe(true); }); }); 5. まとめ 最初にお伝えしたとおり、一見すると工程は複雑に見えますが、まったくそんなことはありません。むしろ実装中に悩んだのは、責務をどう適切に分担するかと、SonarQubeのduplicate line code smellを避けるためにコードの重複をどう抑えるかでした。そのため、コード全体を通して次のような選択をしました。 各オブジェクトの責務を細かく分離するよう努めました。 各オブジェクトの横断的関心事はServiceというlayerに分け、必要なオブジェクトが必要な分だけ参照するように実装しました。 実行時に動くロジックを切り替える必要がある場合は、Strategyパターンを積極的に使いました。 同じロジックで小さな部分だけを変える必要がある場合は、Template Methodパターンを使いましたが、継承が1段階を超えないように実装しました。 実装した構造を簡単に表すと、次のようになります。 実装の紹介は以上です。 次回の記事では、code generatorを導入してから働き方がどのように変わったのかをご紹介します。