バックエンドが生成するapi.jsonからmisskey-jsの型を作成する (#12434)
* ひとまず生成できるところまで * ファイル構成整理 * 生成コマンド整理 * misskey-jsへの組み込み * fix generator.ts * wip * fix generator.ts * fix package.json * 生成ロジックの調整 * 型レベルでのswitch-case機構をmisskey-jsからfrontendに持ち込めるようにした * 型チェック用のtsconfig.jsonを作成 * 他のエンドポイントを呼ぶ関数にも適用 * 未使用エンティティなどを削除 * misskey-js側で手動定義されていた型を自動生成された型に移行(ただしapi.jsonがvalidでなくなってしまったので後で修正する) * messagingは廃止されている(テストのビルドエラー解消) * validなapi.jsonを出力できるように修正 * 修正漏れ対応 * Ajvに怒られて起動できなかったところを修正 * fix ci(途中) * パラメータenumをやめる * add command * add api.json * 都度自動生成をやめる * 一気通貫スクリプト修正 * fix ci * 生成ロジック修正 * フロントの型チェックは結局やらなかったので戻しておく * fix pnpm-lock.yaml * add README.md --------- Co-authored-by: osamu <46447427+sam-osamu@users.noreply.github.com> Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
This commit is contained in:
parent
92029ac325
commit
336416261a
42 changed files with 27053 additions and 3964 deletions
9
packages/misskey-js/generator/.eslintrc.cjs
Normal file
9
packages/misskey-js/generator/.eslintrc.cjs
Normal file
|
@ -0,0 +1,9 @@
|
|||
module.exports = {
|
||||
parserOptions: {
|
||||
tsconfigRootDir: __dirname,
|
||||
project: ['./tsconfig.json'],
|
||||
},
|
||||
extends: [
|
||||
'../../shared/.eslintrc.js',
|
||||
],
|
||||
};
|
1
packages/misskey-js/generator/.gitignore
vendored
Normal file
1
packages/misskey-js/generator/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
api.json
|
19
packages/misskey-js/generator/README.md
Normal file
19
packages/misskey-js/generator/README.md
Normal file
|
@ -0,0 +1,19 @@
|
|||
## misskey-js向け型生成モジュール
|
||||
|
||||
バックエンドが吐き出すOpenAPI準拠のapi.jsonからmisskey-jsで使用される型エイリアスを生成するためのモジュールです。
|
||||
このモジュールはmisskey-jsそのものにバンドルされることは想定しておらず、生成物をmisskey-jsのsrc配下にコピーして使用することを想定しています。
|
||||
|
||||
## 使い方
|
||||
|
||||
まず、Misskeyのバックエンドからapi.jsonを取得する必要があります。任意のMisskeyインスタンスの/api-docからダウンロードしても良いですし、
|
||||
backendモジュール配下で`pnpm generate-api-json`を実行しても良いでしょう。
|
||||
|
||||
api.jsonを入手したら、このファイルがあるディレクトリに置いてください。
|
||||
|
||||
その後、以下コマンドを実行します。
|
||||
|
||||
```shell
|
||||
pnpm generate
|
||||
```
|
||||
|
||||
上記を実行することで、`./built`ディレクトリ配下にtsファイルが生成されます。
|
24
packages/misskey-js/generator/package.json
Normal file
24
packages/misskey-js/generator/package.json
Normal file
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"name": "misskey-js-type-generator",
|
||||
"version": "0.0.0",
|
||||
"description": "Misskey TypeGenerator",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"generate": "tsx src/generator.ts && eslint ./built/**/* --ext .ts --fix"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@apidevtools/swagger-parser": "10.1.0",
|
||||
"@types/node": "20.9.1",
|
||||
"@typescript-eslint/eslint-plugin": "6.11.0",
|
||||
"@typescript-eslint/parser": "6.11.0",
|
||||
"eslint": "8.53.0",
|
||||
"typescript": "5.3.2",
|
||||
"tsx": "4.4.0",
|
||||
"ts-case-convert": "2.0.2",
|
||||
"openapi-types": "12.1.3",
|
||||
"openapi-typescript": "6.7.1"
|
||||
},
|
||||
"files": [
|
||||
"built"
|
||||
]
|
||||
}
|
284
packages/misskey-js/generator/src/generator.ts
Normal file
284
packages/misskey-js/generator/src/generator.ts
Normal file
|
@ -0,0 +1,284 @@
|
|||
import { mkdir, writeFile } from 'fs/promises';
|
||||
import { OpenAPIV3 } from 'openapi-types';
|
||||
import { toPascal } from 'ts-case-convert';
|
||||
import SwaggerParser from '@apidevtools/swagger-parser';
|
||||
import openapiTS from 'openapi-typescript';
|
||||
|
||||
function generateVersionHeaderComment(openApiDocs: OpenAPIV3.Document): string {
|
||||
const contents = {
|
||||
version: openApiDocs.info.version,
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push('/*');
|
||||
for (const [key, value] of Object.entries(contents)) {
|
||||
lines.push(` * ${key}: ${value}`);
|
||||
}
|
||||
lines.push(' */');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
async function generateBaseTypes(
|
||||
openApiDocs: OpenAPIV3.Document,
|
||||
openApiJsonPath: string,
|
||||
typeFileName: string,
|
||||
) {
|
||||
const disabledLints = [
|
||||
'@typescript-eslint/naming-convention',
|
||||
'@typescript-eslint/no-explicit-any',
|
||||
];
|
||||
|
||||
const lines: string[] = [];
|
||||
for (const lint of disabledLints) {
|
||||
lines.push(`/* eslint ${lint}: 0 */`);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
lines.push(generateVersionHeaderComment(openApiDocs));
|
||||
lines.push('');
|
||||
|
||||
const generatedTypes = await openapiTS(openApiJsonPath, { exportType: true });
|
||||
lines.push(generatedTypes);
|
||||
lines.push('');
|
||||
|
||||
await writeFile(typeFileName, lines.join('\n'));
|
||||
}
|
||||
|
||||
async function generateSchemaEntities(
|
||||
openApiDocs: OpenAPIV3.Document,
|
||||
typeFileName: string,
|
||||
outputPath: string,
|
||||
) {
|
||||
if (!openApiDocs.components?.schemas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const schemas = openApiDocs.components.schemas;
|
||||
const schemaNames = Object.keys(schemas);
|
||||
const typeAliasLines: string[] = [];
|
||||
|
||||
typeAliasLines.push(generateVersionHeaderComment(openApiDocs));
|
||||
typeAliasLines.push('');
|
||||
typeAliasLines.push(`import { components } from '${toImportPath(typeFileName)}';`);
|
||||
typeAliasLines.push(
|
||||
...schemaNames.map(it => `export type ${it} = components['schemas']['${it}'];`),
|
||||
);
|
||||
typeAliasLines.push('');
|
||||
|
||||
await writeFile(outputPath, typeAliasLines.join('\n'));
|
||||
}
|
||||
|
||||
async function generateEndpoints(
|
||||
openApiDocs: OpenAPIV3.Document,
|
||||
typeFileName: string,
|
||||
entitiesOutputPath: string,
|
||||
endpointOutputPath: string,
|
||||
) {
|
||||
const endpoints: Endpoint[] = [];
|
||||
|
||||
// misskey-jsはPOST固定で送っているので、こちらも決め打ちする。別メソッドに対応することがあればこちらも直す必要あり
|
||||
const paths = openApiDocs.paths;
|
||||
const postPathItems = Object.keys(paths)
|
||||
.map(it => paths[it]?.post)
|
||||
.filter(filterUndefined);
|
||||
|
||||
for (const operation of postPathItems) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const operationId = operation.operationId!;
|
||||
const endpoint = new Endpoint(operationId);
|
||||
endpoints.push(endpoint);
|
||||
|
||||
if (isRequestBodyObject(operation.requestBody)) {
|
||||
const reqContent = operation.requestBody.content;
|
||||
const supportMediaTypes = Object.keys(reqContent);
|
||||
if (supportMediaTypes.length > 0) {
|
||||
// いまのところ複数のメディアタイプをとるエンドポイントは無いので決め打ちする
|
||||
endpoint.request = new OperationTypeAlias(
|
||||
operationId,
|
||||
supportMediaTypes[0],
|
||||
OperationsAliasType.REQUEST,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isResponseObject(operation.responses['200']) && operation.responses['200'].content) {
|
||||
const resContent = operation.responses['200'].content;
|
||||
const supportMediaTypes = Object.keys(resContent);
|
||||
if (supportMediaTypes.length > 0) {
|
||||
// いまのところ複数のメディアタイプを返すエンドポイントは無いので決め打ちする
|
||||
endpoint.response = new OperationTypeAlias(
|
||||
operationId,
|
||||
supportMediaTypes[0],
|
||||
OperationsAliasType.RESPONSE,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const entitiesOutputLine: string[] = [];
|
||||
|
||||
entitiesOutputLine.push(generateVersionHeaderComment(openApiDocs));
|
||||
entitiesOutputLine.push('');
|
||||
|
||||
entitiesOutputLine.push(`import { operations } from '${toImportPath(typeFileName)}';`);
|
||||
entitiesOutputLine.push('');
|
||||
|
||||
entitiesOutputLine.push(new EmptyTypeAlias(OperationsAliasType.REQUEST).toLine());
|
||||
entitiesOutputLine.push(new EmptyTypeAlias(OperationsAliasType.RESPONSE).toLine());
|
||||
entitiesOutputLine.push('');
|
||||
|
||||
const entities = endpoints
|
||||
.flatMap(it => [it.request, it.response].filter(i => i))
|
||||
.filter(filterUndefined);
|
||||
entitiesOutputLine.push(...entities.map(it => it.toLine()));
|
||||
entitiesOutputLine.push('');
|
||||
|
||||
await writeFile(entitiesOutputPath, entitiesOutputLine.join('\n'));
|
||||
|
||||
const endpointOutputLine: string[] = [];
|
||||
|
||||
endpointOutputLine.push(generateVersionHeaderComment(openApiDocs));
|
||||
endpointOutputLine.push('');
|
||||
|
||||
endpointOutputLine.push('import type {');
|
||||
endpointOutputLine.push(
|
||||
...[emptyRequest, emptyResponse, ...entities].map(it => '\t' + it.generateName() + ','),
|
||||
);
|
||||
endpointOutputLine.push(`} from '${toImportPath(entitiesOutputPath)}';`);
|
||||
endpointOutputLine.push('');
|
||||
|
||||
endpointOutputLine.push('export type Endpoints = {');
|
||||
endpointOutputLine.push(
|
||||
...endpoints.map(it => '\t' + it.toLine()),
|
||||
);
|
||||
endpointOutputLine.push('}');
|
||||
endpointOutputLine.push('');
|
||||
|
||||
await writeFile(endpointOutputPath, endpointOutputLine.join('\n'));
|
||||
}
|
||||
|
||||
function isRequestBodyObject(value: unknown): value is OpenAPIV3.RequestBodyObject {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { content } = value as Record<keyof OpenAPIV3.RequestBodyObject, unknown>;
|
||||
return content !== undefined;
|
||||
}
|
||||
|
||||
function isResponseObject(value: unknown): value is OpenAPIV3.ResponseObject {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { description } = value as Record<keyof OpenAPIV3.ResponseObject, unknown>;
|
||||
return description !== undefined;
|
||||
}
|
||||
|
||||
function filterUndefined<T>(item: T): item is Exclude<T, undefined> {
|
||||
return item !== undefined;
|
||||
}
|
||||
|
||||
function toImportPath(fileName: string, fromPath = '/built/autogen', toPath = ''): string {
|
||||
return fileName.replace(fromPath, toPath).replace('.ts', '.js');
|
||||
}
|
||||
|
||||
enum OperationsAliasType {
|
||||
REQUEST = 'Request',
|
||||
RESPONSE = 'Response'
|
||||
}
|
||||
|
||||
interface IOperationTypeAlias {
|
||||
readonly type: OperationsAliasType
|
||||
|
||||
generateName(): string
|
||||
|
||||
toLine(): string
|
||||
}
|
||||
|
||||
class OperationTypeAlias implements IOperationTypeAlias {
|
||||
public readonly operationId: string;
|
||||
public readonly mediaType: string;
|
||||
public readonly type: OperationsAliasType;
|
||||
|
||||
constructor(
|
||||
operationId: string,
|
||||
mediaType: string,
|
||||
type: OperationsAliasType,
|
||||
) {
|
||||
this.operationId = operationId;
|
||||
this.mediaType = mediaType;
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
generateName(): string {
|
||||
const nameBase = this.operationId.replace(/\//g, '-');
|
||||
return toPascal(nameBase + this.type);
|
||||
}
|
||||
|
||||
toLine(): string {
|
||||
const name = this.generateName();
|
||||
return (this.type === OperationsAliasType.REQUEST)
|
||||
? `export type ${name} = operations['${this.operationId}']['requestBody']['content']['${this.mediaType}'];`
|
||||
: `export type ${name} = operations['${this.operationId}']['responses']['200']['content']['${this.mediaType}'];`;
|
||||
}
|
||||
}
|
||||
|
||||
class EmptyTypeAlias implements IOperationTypeAlias {
|
||||
readonly type: OperationsAliasType;
|
||||
|
||||
constructor(type: OperationsAliasType) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
generateName(): string {
|
||||
return 'Empty' + this.type;
|
||||
}
|
||||
|
||||
toLine(): string {
|
||||
const name = this.generateName();
|
||||
return `export type ${name} = Record<string, unknown> | undefined;`;
|
||||
}
|
||||
}
|
||||
|
||||
const emptyRequest = new EmptyTypeAlias(OperationsAliasType.REQUEST);
|
||||
const emptyResponse = new EmptyTypeAlias(OperationsAliasType.RESPONSE);
|
||||
|
||||
class Endpoint {
|
||||
public readonly operationId: string;
|
||||
public request?: IOperationTypeAlias;
|
||||
public response?: IOperationTypeAlias;
|
||||
|
||||
constructor(operationId: string) {
|
||||
this.operationId = operationId;
|
||||
}
|
||||
|
||||
toLine(): string {
|
||||
const reqName = this.request?.generateName() ?? emptyRequest.generateName();
|
||||
const resName = this.response?.generateName() ?? emptyResponse.generateName();
|
||||
|
||||
return `'${this.operationId}': { req: ${reqName}; res: ${resName} };`;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const generatePath = './built/autogen';
|
||||
await mkdir(generatePath, { recursive: true });
|
||||
|
||||
const openApiJsonPath = './api.json';
|
||||
const openApiDocs = await SwaggerParser.validate(openApiJsonPath) as OpenAPIV3.Document;
|
||||
|
||||
const typeFileName = './built/autogen/types.ts';
|
||||
await generateBaseTypes(openApiDocs, openApiJsonPath, typeFileName);
|
||||
|
||||
const modelFileName = `${generatePath}/models.ts`;
|
||||
await generateSchemaEntities(openApiDocs, typeFileName, modelFileName);
|
||||
|
||||
const entitiesFileName = `${generatePath}/entities.ts`;
|
||||
const endpointFileName = `${generatePath}/endpoint.ts`;
|
||||
await generateEndpoints(openApiDocs, typeFileName, entitiesFileName, endpointFileName);
|
||||
}
|
||||
|
||||
main();
|
20
packages/misskey-js/generator/tsconfig.json
Normal file
20
packages/misskey-js/generator/tsconfig.json
Normal file
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "nodenext",
|
||||
"strict": true,
|
||||
"strictFunctionTypes": true,
|
||||
"strictNullChecks": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": [
|
||||
"esnext",
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"built/**/*.ts"
|
||||
],
|
||||
"exclude": []
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue