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 2023-03-09 18:37:44 +01:00 committed by GitHub
parent caf646fcb0
commit e0b7633a7a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 270 additions and 29 deletions

View File

@ -17,9 +17,11 @@ You should also include the user name that made the change.
- ノートごとに絵文字リアクションを受け取るか設定できるように - ノートごとに絵文字リアクションを受け取るか設定できるように
- enhance(client): DM作成時にメンションも含むように - enhance(client): DM作成時にメンションも含むように
- enhance(client): フォロー申請のボタンのデザインを改善 - enhance(client): フォロー申請のボタンのデザインを改善
- enhance(backend): OpenAPIエンドポイントを復旧
### Bugfixes ### Bugfixes
- ロールで広告を無効にするとadmin/adsでプレビューがでてこない問題を修正 - ロールで広告を無効にするとadmin/adsでプレビューがでてこない問題を修正
- /api-consoleページにアクセスすると404が出る問題を修正
## 13.9.2 (2023/03/06) ## 13.9.2 (2023/03/06)

View File

@ -19,6 +19,6 @@
</head> </head>
<body> <body>
<redoc spec-url="/api.json" expand-responses="200" expand-single-schema-field="true"></redoc> <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> </body>
</html> </html>

View File

@ -2,7 +2,6 @@ import * as fs from 'node:fs';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path'; import { dirname } from 'node:path';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import fastifyStatic from '@fastify/static';
import rename from 'rename'; import rename from 'rename';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import type { DriveFile, DriveFilesRepository } from '@/models/index.js'; import type { DriveFile, DriveFilesRepository } from '@/models/index.js';
@ -60,11 +59,6 @@ export class FileServerService {
done(); done();
}); });
fastify.register(fastifyStatic, {
root: _dirname,
serve: false,
});
fastify.get('/files/app-default.jpg', (request, reply) => { fastify.get('/files/app-default.jpg', (request, reply) => {
const file = fs.createReadStream(`${_dirname}/assets/dummy.png`); const file = fs.createReadStream(`${_dirname}/assets/dummy.png`);
reply.header('Content-Type', 'image/jpeg'); reply.header('Content-Type', 'image/jpeg');

View File

@ -33,6 +33,7 @@ import { LocalTimelineChannelService } from './api/stream/channels/local-timelin
import { QueueStatsChannelService } from './api/stream/channels/queue-stats.js'; import { QueueStatsChannelService } from './api/stream/channels/queue-stats.js';
import { ServerStatsChannelService } from './api/stream/channels/server-stats.js'; import { ServerStatsChannelService } from './api/stream/channels/server-stats.js';
import { UserListChannelService } from './api/stream/channels/user-list.js'; import { UserListChannelService } from './api/stream/channels/user-list.js';
import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
@Module({ @Module({
imports: [ imports: [
@ -72,6 +73,7 @@ import { UserListChannelService } from './api/stream/channels/user-list.js';
QueueStatsChannelService, QueueStatsChannelService,
ServerStatsChannelService, ServerStatsChannelService,
UserListChannelService, UserListChannelService,
OpenApiServerService,
], ],
exports: [ exports: [
ServerService, ServerService,

View File

@ -1,7 +1,9 @@
import cluster from 'node:cluster'; import cluster from 'node:cluster';
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import Fastify, { FastifyInstance } from 'fastify'; import Fastify, { FastifyInstance } from 'fastify';
import fastifyStatic from '@fastify/static';
import { IsNull } from 'typeorm'; import { IsNull } from 'typeorm';
import { GlobalEventService } from '@/core/GlobalEventService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
@ -21,6 +23,9 @@ import { StreamingApiServerService } from './api/StreamingApiServerService.js';
import { WellKnownServerService } from './WellKnownServerService.js'; import { WellKnownServerService } from './WellKnownServerService.js';
import { FileServerService } from './FileServerService.js'; import { FileServerService } from './FileServerService.js';
import { ClientServerService } from './web/ClientServerService.js'; import { ClientServerService } from './web/ClientServerService.js';
import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
const _dirname = fileURLToPath(new URL('.', import.meta.url));
@Injectable() @Injectable()
export class ServerService implements OnApplicationShutdown { export class ServerService implements OnApplicationShutdown {
@ -42,6 +47,7 @@ export class ServerService implements OnApplicationShutdown {
private userEntityService: UserEntityService, private userEntityService: UserEntityService,
private apiServerService: ApiServerService, private apiServerService: ApiServerService,
private openApiServerService: OpenApiServerService,
private streamingApiServerService: StreamingApiServerService, private streamingApiServerService: StreamingApiServerService,
private activityPubServerService: ActivityPubServerService, private activityPubServerService: ActivityPubServerService,
private wellKnownServerService: WellKnownServerService, 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.apiServerService.createServer, { prefix: '/api' });
fastify.register(this.openApiServerService.createServer);
fastify.register(this.fileServerService.createServer); fastify.register(this.fileServerService.createServer);
fastify.register(this.activityPubServerService.createServer); fastify.register(this.activityPubServerService.createServer);
fastify.register(this.nodeinfoServerService.createServer); fastify.register(this.nodeinfoServerService.createServer);

View File

@ -167,7 +167,7 @@ export class ApiServerService {
// Make sure any unknown path under /api returns HTTP 404 Not Found, // Make sure any unknown path under /api returns HTTP 404 Not Found,
// because otherwise ClientServerService will return the base client HTML // because otherwise ClientServerService will return the base client HTML
// page with HTTP 200. // page with HTTP 200.
fastify.get('*', (request, reply) => { fastify.get('/*', (request, reply) => {
reply.code(404); reply.code(404);
// Mock ApiCallService.send's error handling // Mock ApiCallService.send's error handling
reply.send({ reply.send({

View File

@ -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();
}
}

View 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;
}

View File

@ -194,11 +194,6 @@ export class ClientServerService {
//#region static assets //#region static assets
fastify.register(fastifyStatic, {
root: _dirname,
serve: false,
});
fastify.register(fastifyStatic, { fastify.register(fastifyStatic, {
root: staticAssets, root: staticAssets,
prefix: '/static-assets/', prefix: '/static-assets/',

View File

@ -13,6 +13,7 @@ const UNSPECIFIED = '*/*';
// Response Content-Type // Response Content-Type
const AP = 'application/activity+json; charset=utf-8'; const AP = 'application/activity+json; charset=utf-8';
const HTML = 'text/html; charset=utf-8'; const HTML = 'text/html; charset=utf-8';
const JSON_UTF8 = 'application/json; charset=utf-8';
describe('Fetch resource', () => { describe('Fetch resource', () => {
let p: INestApplicationContext; let p: INestApplicationContext;
@ -52,14 +53,17 @@ describe('Fetch resource', () => {
assert.strictEqual(res.type, HTML); assert.strictEqual(res.type, HTML);
}); });
test('GET api-doc (廃止)', async () => { test('GET api-doc', async () => {
const res = await simpleGet('/api-doc'); 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'); 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 () => { test('GET api/foo (存在しない)', async () => {
@ -68,6 +72,12 @@ describe('Fetch resource', () => {
assert.strictEqual(res.body.error.code, 'UNKNOWN_API_ENDPOINT'); 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 () => { test('GET favicon.ico', async () => {
const res = await simpleGet('/favicon.ico'); const res = await simpleGet('/favicon.ico');
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);