diff options
Diffstat (limited to 'packages/backend/src/server/api/openapi')
| -rw-r--r-- | packages/backend/src/server/api/openapi/OpenApiServerService.ts | 31 | ||||
| -rw-r--r-- | packages/backend/src/server/api/openapi/gen-spec.ts | 193 |
2 files changed, 224 insertions, 0 deletions
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; +} |