From e0b7633a7adb6f2744e1142637bbbd6ac6624031 Mon Sep 17 00:00:00 2001
From: Kagami Sascha Rosylight <saschanaz@outlook.com>
Date: Thu, 9 Mar 2023 18:37:44 +0100
Subject: [PATCH] enhance(backend): restore OpenAPI endpoints (#10281)

* enhance(backend): restore OpenAPI endpoints

* Update CHANGELOG.md

* version

* set max-age

* update redoc

* follow redoc documentation

---------

Co-authored-by: tamaina <tamaina@hotmail.co.jp>
---
 CHANGELOG.md                                  |  16 +-
 packages/backend/assets/redoc.html            |   2 +-
 .../backend/src/server/FileServerService.ts   |  16 +-
 packages/backend/src/server/ServerModule.ts   |   2 +
 packages/backend/src/server/ServerService.ts  |  14 ++
 .../src/server/api/ApiServerService.ts        |   2 +-
 .../api/openapi/OpenApiServerService.ts       |  31 +++
 .../src/server/api/openapi/gen-spec.ts        | 193 ++++++++++++++++++
 .../src/server/web/ClientServerService.ts     |   5 -
 packages/backend/test/e2e/fetch-resource.ts   |  18 +-
 10 files changed, 270 insertions(+), 29 deletions(-)
 create mode 100644 packages/backend/src/server/api/openapi/OpenApiServerService.ts
 create mode 100644 packages/backend/src/server/api/openapi/gen-spec.ts

diff --git a/CHANGELOG.md b/CHANGELOG.md
index e79bbbac36..f3588a10ab 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,10 +2,10 @@
 ## 13.x.x (unreleased)
 
 ### Improvements
-- 
+-
 
 ### Bugfixes
-- 
+-
 
 You should also include the user name that made the change.
 -->
@@ -17,9 +17,11 @@ You should also include the user name that made the change.
 - ノートごとに絵文字リアクションを受け取るか設定できるように
 - enhance(client): DM作成時にメンションも含むように
 - enhance(client): フォロー申請のボタンのデザインを改善
+- enhance(backend): OpenAPIエンドポイントを復旧
 
 ### Bugfixes
 - ロールで広告を無効にするとadmin/adsでプレビューがでてこない問題を修正
+- /api-consoleページにアクセスすると404が出る問題を修正
 
 ## 13.9.2 (2023/03/06)
 
@@ -257,8 +259,8 @@ You should also include the user name that made the change.
 ## 13.3.2 (2023/02/04)
 
 ### Improvements
-- 外部メディアプロキシへの対応を強化しました  
-  外部メディアプロキシのFastify実装を作りました  
+- 外部メディアプロキシへの対応を強化しました
+  外部メディアプロキシのFastify実装を作りました
   https://github.com/misskey-dev/media-proxy
 - Server: improve performance
 
@@ -421,7 +423,7 @@ You should also include the user name that made the change.
 	- ユーザーごとのドライブ容量設定はロールに統合されました。
 	- インスタンスデフォルトのドライブ容量設定はロールに統合されました。アップデート後、ベースロールもしくはコンディショナルロールでドライブ容量を編集してください。
 	- LTL/GTLの解放状態はロールに統合されました。
-- Dockerの実行をrootで行わないようにしました。Dockerかつオブジェクトストレージを使用していない場合は`chown -hR 991.991 ./files`を実行してください。  
+- Dockerの実行をrootで行わないようにしました。Dockerかつオブジェクトストレージを使用していない場合は`chown -hR 991.991 ./files`を実行してください。
   https://github.com/misskey-dev/misskey/pull/9560
 
 #### For users
@@ -649,7 +651,7 @@ You should also include the user name that made the change.
 ## 12.112.2 (2022/07/08)
 
 ### Bugfixes
-- Fix Docker doesn't work @mei23  
+- Fix Docker doesn't work @mei23
   Still not working on arm64 environment. (See 12.112.0)
 
 ## 12.112.1 (2022/07/07)
@@ -691,7 +693,7 @@ same as 12.112.0
 - Improve player detection in URL preview @mei23
 - Add Badge Image to Push Notification #8012 @tamaina
 - Server: Improve performance
-- Server: Supports IPv6 on Redis transport. @mei23  
+- Server: Supports IPv6 on Redis transport. @mei23
   IPv4/IPv6 is used by default. You can tune this behavior via `redis.family`.
 - Server: Add possibility to log IP addresses of users @syuilo
 - Add additional drive capacity change support @CyberRex0
diff --git a/packages/backend/assets/redoc.html b/packages/backend/assets/redoc.html
index 9ee5a95c05..a9ebf662fc 100644
--- a/packages/backend/assets/redoc.html
+++ b/packages/backend/assets/redoc.html
@@ -19,6 +19,6 @@
 	</head>
 	<body>
 		<redoc spec-url="/api.json" expand-responses="200" expand-single-schema-field="true"></redoc>
-		<script src="https://cdn.jsdelivr.net/npm/redoc@2.0.0-rc.50/bundles/redoc.standalone.js" integrity="sha256-WJbngBWN9vp6vkEuzeoSj5tE5saW9Hfj6/SinkzhL2s=" crossorigin="anonymous"></script>
+		<script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"></script>
 	</body>
 </html>
diff --git a/packages/backend/src/server/FileServerService.ts b/packages/backend/src/server/FileServerService.ts
index 835657b625..6db9a9672c 100644
--- a/packages/backend/src/server/FileServerService.ts
+++ b/packages/backend/src/server/FileServerService.ts
@@ -2,7 +2,6 @@ import * as fs from 'node:fs';
 import { fileURLToPath } from 'node:url';
 import { dirname } from 'node:path';
 import { Inject, Injectable } from '@nestjs/common';
-import fastifyStatic from '@fastify/static';
 import rename from 'rename';
 import type { Config } from '@/config.js';
 import type { DriveFile, DriveFilesRepository } from '@/models/index.js';
@@ -60,11 +59,6 @@ export class FileServerService {
 			done();
 		});
 
-		fastify.register(fastifyStatic, {
-			root: _dirname,
-			serve: false,
-		});
-
 		fastify.get('/files/app-default.jpg', (request, reply) => {
 			const file = fs.createReadStream(`${_dirname}/assets/dummy.png`);
 			reply.header('Content-Type', 'image/jpeg');
@@ -311,20 +305,20 @@ export class FileServerService {
 					.linear(1.75, -(128 * 1.75) + 128) // 1.75x contrast
 					.flatten({ background: '#000' })
 					.toColorspace('b-w');
-	
+
 				const stats = await mask.clone().stats();
-	
+
 				if (stats.entropy < 0.1) {
 					// エントロピーがあまりない場合は404にする
 					throw new StatusError('Skip to provide badge', 404);
 				}
-	
+
 				const data = sharp({
 					create: { width: 96, height: 96, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } },
 				})
 					.pipelineColorspace('b-w')
 					.boolean(await mask.png().toBuffer(), 'eor');
-	
+
 				image = {
 					data: await data.png().toBuffer(),
 					ext: 'png',
@@ -396,7 +390,7 @@ export class FileServerService {
 			const { filename } = await this.downloadService.downloadUrl(url, path);
 
 			const { mime, ext } = await this.fileInfoService.detectType(path);
-	
+
 			return {
 				state: 'remote',
 				mime, ext,
diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts
index a5a5f9e7f9..6bae0bafda 100644
--- a/packages/backend/src/server/ServerModule.ts
+++ b/packages/backend/src/server/ServerModule.ts
@@ -33,6 +33,7 @@ import { LocalTimelineChannelService } from './api/stream/channels/local-timelin
 import { QueueStatsChannelService } from './api/stream/channels/queue-stats.js';
 import { ServerStatsChannelService } from './api/stream/channels/server-stats.js';
 import { UserListChannelService } from './api/stream/channels/user-list.js';
+import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
 
 @Module({
 	imports: [
@@ -72,6 +73,7 @@ import { UserListChannelService } from './api/stream/channels/user-list.js';
 		QueueStatsChannelService,
 		ServerStatsChannelService,
 		UserListChannelService,
+		OpenApiServerService,
 	],
 	exports: [
 		ServerService,
diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts
index e61383468c..3f116845cb 100644
--- a/packages/backend/src/server/ServerService.ts
+++ b/packages/backend/src/server/ServerService.ts
@@ -1,7 +1,9 @@
 import cluster from 'node:cluster';
 import * as fs from 'node:fs';
+import { fileURLToPath } from 'node:url';
 import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
 import Fastify, { FastifyInstance } from 'fastify';
+import fastifyStatic from '@fastify/static';
 import { IsNull } from 'typeorm';
 import { GlobalEventService } from '@/core/GlobalEventService.js';
 import type { Config } from '@/config.js';
@@ -21,6 +23,9 @@ import { StreamingApiServerService } from './api/StreamingApiServerService.js';
 import { WellKnownServerService } from './WellKnownServerService.js';
 import { FileServerService } from './FileServerService.js';
 import { ClientServerService } from './web/ClientServerService.js';
+import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
+
+const _dirname = fileURLToPath(new URL('.', import.meta.url));
 
 @Injectable()
 export class ServerService implements OnApplicationShutdown {
@@ -42,6 +47,7 @@ export class ServerService implements OnApplicationShutdown {
 
 		private userEntityService: UserEntityService,
 		private apiServerService: ApiServerService,
+		private openApiServerService: OpenApiServerService,
 		private streamingApiServerService: StreamingApiServerService,
 		private activityPubServerService: ActivityPubServerService,
 		private wellKnownServerService: WellKnownServerService,
@@ -71,7 +77,15 @@ export class ServerService implements OnApplicationShutdown {
 			});
 		}
 
+		// Register non-serving static server so that the child services can use reply.sendFile.
+		// `root` here is just a placeholder and each call must use its own `rootPath`.
+		fastify.register(fastifyStatic, {
+			root: _dirname,
+			serve: false,
+		});
+
 		fastify.register(this.apiServerService.createServer, { prefix: '/api' });
+		fastify.register(this.openApiServerService.createServer);
 		fastify.register(this.fileServerService.createServer);
 		fastify.register(this.activityPubServerService.createServer);
 		fastify.register(this.nodeinfoServerService.createServer);
diff --git a/packages/backend/src/server/api/ApiServerService.ts b/packages/backend/src/server/api/ApiServerService.ts
index 115d60986c..b806ad5ca3 100644
--- a/packages/backend/src/server/api/ApiServerService.ts
+++ b/packages/backend/src/server/api/ApiServerService.ts
@@ -167,7 +167,7 @@ export class ApiServerService {
 		// Make sure any unknown path under /api returns HTTP 404 Not Found,
 		// because otherwise ClientServerService will return the base client HTML
 		// page with HTTP 200.
-		fastify.get('*', (request, reply) => {
+		fastify.get('/*', (request, reply) => {
 			reply.code(404);
 			// Mock ApiCallService.send's error handling
 			reply.send({
diff --git a/packages/backend/src/server/api/openapi/OpenApiServerService.ts b/packages/backend/src/server/api/openapi/OpenApiServerService.ts
new file mode 100644
index 0000000000..e804ba276c
--- /dev/null
+++ b/packages/backend/src/server/api/openapi/OpenApiServerService.ts
@@ -0,0 +1,31 @@
+import { fileURLToPath } from 'node:url';
+import { Inject, Injectable } from '@nestjs/common';
+import type { Config } from '@/config.js';
+import { DI } from '@/di-symbols.js';
+import { bindThis } from '@/decorators.js';
+import { genOpenapiSpec } from './gen-spec.js';
+import type { FastifyInstance, FastifyPluginOptions } from 'fastify';
+
+const staticAssets = fileURLToPath(new URL('../../../../assets/', import.meta.url));
+
+@Injectable()
+export class OpenApiServerService {
+	constructor(
+		@Inject(DI.config)
+		private config: Config,
+	) {
+	}
+
+	@bindThis
+	public createServer(fastify: FastifyInstance, _options: FastifyPluginOptions, done: (err?: Error) => void) {
+		fastify.get('/api-doc', async (_request, reply) => {
+			reply.header('Cache-Control', 'public, max-age=86400');
+			return await reply.sendFile('/redoc.html', staticAssets);
+		});
+		fastify.get('/api.json', (_request, reply) => {
+			reply.header('Cache-Control', 'public, max-age=600');
+			reply.send(genOpenapiSpec(this.config));
+		});
+		done();
+	}
+}
diff --git a/packages/backend/src/server/api/openapi/gen-spec.ts b/packages/backend/src/server/api/openapi/gen-spec.ts
new file mode 100644
index 0000000000..fa62480c02
--- /dev/null
+++ b/packages/backend/src/server/api/openapi/gen-spec.ts
@@ -0,0 +1,193 @@
+import type { Config } from '@/config.js';
+import endpoints from '../endpoints.js';
+import { errors as basicErrors } from './errors.js';
+import { schemas, convertSchemaToOpenApiSchema } from './schemas.js';
+
+export function genOpenapiSpec(config: Config) {
+	const spec = {
+		openapi: '3.0.0',
+
+		info: {
+			version: config.version,
+			title: 'Misskey API',
+			'x-logo': { url: '/static-assets/api-doc.png' },
+		},
+
+		externalDocs: {
+			description: 'Repository',
+			url: 'https://github.com/misskey-dev/misskey',
+		},
+
+		servers: [{
+			url: config.apiUrl,
+		}],
+
+		paths: {} as any,
+
+		components: {
+			schemas: schemas,
+
+			securitySchemes: {
+				ApiKeyAuth: {
+					type: 'apiKey',
+					in: 'body',
+					name: 'i',
+				},
+			},
+		},
+	};
+
+	for (const endpoint of endpoints.filter(ep => !ep.meta.secure)) {
+		const errors = {} as any;
+
+		if (endpoint.meta.errors) {
+			for (const e of Object.values(endpoint.meta.errors)) {
+				errors[e.code] = {
+					value: {
+						error: e,
+					},
+				};
+			}
+		}
+
+		const resSchema = endpoint.meta.res ? convertSchemaToOpenApiSchema(endpoint.meta.res) : {};
+
+		let desc = (endpoint.meta.description ? endpoint.meta.description : 'No description provided.') + '\n\n';
+		desc += `**Credential required**: *${endpoint.meta.requireCredential ? 'Yes' : 'No'}*`;
+		if (endpoint.meta.kind) {
+			const kind = endpoint.meta.kind;
+			desc += ` / **Permission**: *${kind}*`;
+		}
+
+		const requestType = endpoint.meta.requireFile ? 'multipart/form-data' : 'application/json';
+		const schema = { ...endpoint.params };
+
+		if (endpoint.meta.requireFile) {
+			schema.properties = {
+				...schema.properties,
+				file: {
+					type: 'string',
+					format: 'binary',
+					description: 'The file contents.',
+				},
+			};
+			schema.required = [...schema.required ?? [], 'file'];
+		}
+
+		const info = {
+			operationId: endpoint.name,
+			summary: endpoint.name,
+			description: desc,
+			externalDocs: {
+				description: 'Source code',
+				url: `https://github.com/misskey-dev/misskey/blob/develop/packages/backend/src/server/api/endpoints/${endpoint.name}.ts`,
+			},
+			...(endpoint.meta.tags ? {
+				tags: [endpoint.meta.tags[0]],
+			} : {}),
+			...(endpoint.meta.requireCredential ? {
+				security: [{
+					ApiKeyAuth: [],
+				}],
+			} : {}),
+			requestBody: {
+				required: true,
+				content: {
+					[requestType]: {
+						schema,
+					},
+				},
+			},
+			responses: {
+				...(endpoint.meta.res ? {
+					'200': {
+						description: 'OK (with results)',
+						content: {
+							'application/json': {
+								schema: resSchema,
+							},
+						},
+					},
+				} : {
+					'204': {
+						description: 'OK (without any results)',
+					},
+				}),
+				'400': {
+					description: 'Client error',
+					content: {
+						'application/json': {
+							schema: {
+								$ref: '#/components/schemas/Error',
+							},
+							examples: { ...errors, ...basicErrors['400'] },
+						},
+					},
+				},
+				'401': {
+					description: 'Authentication error',
+					content: {
+						'application/json': {
+							schema: {
+								$ref: '#/components/schemas/Error',
+							},
+							examples: basicErrors['401'],
+						},
+					},
+				},
+				'403': {
+					description: 'Forbidden error',
+					content: {
+						'application/json': {
+							schema: {
+								$ref: '#/components/schemas/Error',
+							},
+							examples: basicErrors['403'],
+						},
+					},
+				},
+				'418': {
+					description: 'I\'m Ai',
+					content: {
+						'application/json': {
+							schema: {
+								$ref: '#/components/schemas/Error',
+							},
+							examples: basicErrors['418'],
+						},
+					},
+				},
+				...(endpoint.meta.limit ? {
+					'429': {
+						description: 'To many requests',
+						content: {
+							'application/json': {
+								schema: {
+									$ref: '#/components/schemas/Error',
+								},
+								examples: basicErrors['429'],
+							},
+						},
+					},
+				} : {}),
+				'500': {
+					description: 'Internal server error',
+					content: {
+						'application/json': {
+							schema: {
+								$ref: '#/components/schemas/Error',
+							},
+							examples: basicErrors['500'],
+						},
+					},
+				},
+			},
+		};
+
+		spec.paths['/' + endpoint.name] = {
+			post: info,
+		};
+	}
+
+	return spec;
+}
diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts
index 98cdd31206..fb76f07e48 100644
--- a/packages/backend/src/server/web/ClientServerService.ts
+++ b/packages/backend/src/server/web/ClientServerService.ts
@@ -194,11 +194,6 @@ export class ClientServerService {
 
 		//#region static assets
 
-		fastify.register(fastifyStatic, {
-			root: _dirname,
-			serve: false,
-		});
-
 		fastify.register(fastifyStatic, {
 			root: staticAssets,
 			prefix: '/static-assets/',
diff --git a/packages/backend/test/e2e/fetch-resource.ts b/packages/backend/test/e2e/fetch-resource.ts
index 6b3c795235..38cf1c2985 100644
--- a/packages/backend/test/e2e/fetch-resource.ts
+++ b/packages/backend/test/e2e/fetch-resource.ts
@@ -13,6 +13,7 @@ const UNSPECIFIED = '*/*';
 // Response Content-Type
 const AP = 'application/activity+json; charset=utf-8';
 const HTML = 'text/html; charset=utf-8';
+const JSON_UTF8 = 'application/json; charset=utf-8';
 
 describe('Fetch resource', () => {
 	let p: INestApplicationContext;
@@ -52,14 +53,17 @@ describe('Fetch resource', () => {
 			assert.strictEqual(res.type, HTML);
 		});
 
-		test('GET api-doc (廃止)', async () => {
+		test('GET api-doc', async () => {
 			const res = await simpleGet('/api-doc');
-			assert.strictEqual(res.status, 404);
+			assert.strictEqual(res.status, 200);
+			// fastify-static gives charset=UTF-8 instead of utf-8 and that's okay
+			assert.strictEqual(res.type?.toLowerCase(), HTML);
 		});
 
-		test('GET api.json (廃止)', async () => {
+		test('GET api.json', async () => {
 			const res = await simpleGet('/api.json');
-			assert.strictEqual(res.status, 404);
+			assert.strictEqual(res.status, 200);
+			assert.strictEqual(res.type, JSON_UTF8);
 		});
 
 		test('GET api/foo (存在しない)', async () => {
@@ -68,6 +72,12 @@ describe('Fetch resource', () => {
 			assert.strictEqual(res.body.error.code, 'UNKNOWN_API_ENDPOINT');
 		});
 
+		test('GET api-console (client page)', async () => {
+			const res = await simpleGet('/api-console');
+			assert.strictEqual(res.status, 200);
+			assert.strictEqual(res.type, HTML);
+		});
+
 		test('GET favicon.ico', async () => {
 			const res = await simpleGet('/favicon.ico');
 			assert.strictEqual(res.status, 200);