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>
This commit is contained in:
		 Kagami Sascha Rosylight
					Kagami Sascha Rosylight
				
			
				
					committed by
					
						 GitHub
						GitHub
					
				
			
			
				
	
			
			
			 GitHub
						GitHub
					
				
			
						parent
						
							caf646fcb0
						
					
				
				
					commit
					e0b7633a7a
				
			| @@ -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> | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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); | ||||
|   | ||||
| @@ -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({ | ||||
|   | ||||
| @@ -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(); | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										193
									
								
								packages/backend/src/server/api/openapi/gen-spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										193
									
								
								packages/backend/src/server/api/openapi/gen-spec.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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; | ||||
| } | ||||
| @@ -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/', | ||||
|   | ||||
| @@ -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); | ||||
|   | ||||
		Reference in New Issue
	
	Block a user