diff --git a/crowdin.yml b/crowdin.yml index 160b9184d..4c050b1a9 100644 --- a/crowdin.yml +++ b/crowdin.yml @@ -5,3 +5,6 @@ files: - source: /src/docs/ja-JP/*.md translation: /src/docs/%locale%/%original_file_name% update_option: update_as_unapproved + - source: /src/api-docs/ja-JP/**/*.yml + translation: /src/api-docs/%locale%/**/%original_file_name% + update_option: update_as_unapproved diff --git a/gulpfile.ts b/gulpfile.ts index bdc20089c..f123ccd74 100644 --- a/gulpfile.ts +++ b/gulpfile.ts @@ -60,7 +60,14 @@ gulp.task('build:client:style', () => { .pipe(gulp.dest('./built/server/web/')); }); -gulp.task('build:copy', gulp.parallel('build:copy:locales', 'build:copy:views', 'build:client:script', 'build:client:style', 'build:copy:fonts', () => +gulp.task('copy:api-docs', () => + gulp.src([ + './src/api-docs/**/*', + ]) + .pipe(gulp.dest('./built/api-docs/')) +); + +gulp.task('build:copy', gulp.parallel('build:copy:locales', 'copy:api-docs', 'build:copy:views', 'build:client:script', 'build:client:style', 'build:copy:fonts', () => gulp.src([ './src/emojilist.json', './src/server/web/views/**/*', diff --git a/src/api-docs/ja-JP/meta.yml b/src/api-docs/ja-JP/meta.yml new file mode 100644 index 000000000..ce680b820 --- /dev/null +++ b/src/api-docs/ja-JP/meta.yml @@ -0,0 +1,10 @@ +description: "インスタンスのメタ情報を取得します。" + +params: + detail: "追加情報を含めるか否か" + +res: + version: "Misskeyのバージョン" + announcements: "お知らせ" + announcements.title: "タイトル" + announcements.text: "本文" diff --git a/src/api-docs/ja-JP/notes/create.yml b/src/api-docs/ja-JP/notes/create.yml new file mode 100644 index 000000000..add6ff5e3 --- /dev/null +++ b/src/api-docs/ja-JP/notes/create.yml @@ -0,0 +1,7 @@ +description: "ノートを作成します。" + +params: + visibility: "ノートの公開範囲" + +res: + createdNote: "作成したノート" diff --git a/src/client/pages/api-docs/endpoint.vue b/src/client/pages/api-docs/endpoint.vue new file mode 100644 index 000000000..7b9a92867 --- /dev/null +++ b/src/client/pages/api-docs/endpoint.vue @@ -0,0 +1,201 @@ + + + + + diff --git a/src/client/pages/api-docs/index.vue b/src/client/pages/api-docs/index.vue new file mode 100644 index 000000000..b79299de8 --- /dev/null +++ b/src/client/pages/api-docs/index.vue @@ -0,0 +1,39 @@ + + + diff --git a/src/client/pages/api-docs/value.array.vue b/src/client/pages/api-docs/value.array.vue new file mode 100644 index 000000000..887165215 --- /dev/null +++ b/src/client/pages/api-docs/value.array.vue @@ -0,0 +1,46 @@ + + + + + diff --git a/src/client/pages/api-docs/value.object.vue b/src/client/pages/api-docs/value.object.vue new file mode 100644 index 000000000..6b7aadf7e --- /dev/null +++ b/src/client/pages/api-docs/value.object.vue @@ -0,0 +1,59 @@ + + + + + diff --git a/src/client/pages/api-docs/value.vue b/src/client/pages/api-docs/value.vue new file mode 100644 index 000000000..3027d373c --- /dev/null +++ b/src/client/pages/api-docs/value.vue @@ -0,0 +1,106 @@ + + + + + diff --git a/src/client/pages/docs.vue b/src/client/pages/docs.vue index 59d23efcb..05d1f407b 100644 --- a/src/client/pages/docs.vue +++ b/src/client/pages/docs.vue @@ -7,6 +7,11 @@ {{ doc.title }} + diff --git a/src/client/router.ts b/src/client/router.ts index 2826f4ac1..6c83836f6 100644 --- a/src/client/router.ts +++ b/src/client/router.ts @@ -28,8 +28,10 @@ export const router = createRouter({ { path: '/about-misskey', component: page('about-misskey') }, { path: '/featured', component: page('featured') }, { path: '/docs', component: page('docs') }, - { path: '/theme-editor', component: page('theme-editor') }, { path: '/docs/:doc', component: page('doc'), props: route => ({ doc: route.params.doc }) }, + { path: '/api-docs', component: page('api-docs/index') }, + { path: '/api-docs/endpoints/:endpoint(.*)', component: page('api-docs/endpoint'), props: route => ({ endpoint: route.params.endpoint }) }, + { path: '/theme-editor', component: page('theme-editor') }, { path: '/explore', component: page('explore') }, { path: '/explore/tags/:tag', props: true, component: page('explore') }, { path: '/search', component: page('search') }, diff --git a/src/client/style.scss b/src/client/style.scss index 076c18161..d96dea282 100644 --- a/src/client/style.scss +++ b/src/client/style.scss @@ -473,6 +473,7 @@ hr { color: #ccc; font-size: 14px; line-height: 1.5; + tab-size: 2; padding: 5px; } diff --git a/src/server/api/endpoints/endpoint.ts b/src/server/api/endpoints/endpoint.ts index 1a04d8bee..faa76b687 100644 --- a/src/server/api/endpoints/endpoint.ts +++ b/src/server/api/endpoints/endpoint.ts @@ -1,6 +1,8 @@ import $ from 'cafy'; import define from '../define'; import endpoints from '../endpoints'; +import { genOpenapiSpecForEndpoint } from '../openapi/gen-spec'; +import { schemas } from '../openapi/schemas'; export const meta = { requireCredential: false as const, @@ -9,18 +11,28 @@ export const meta = { params: { endpoint: { + // TODO: セキュリティリスクになりうるためバリデーションしたい validator: $.str, + }, + lang: { + // TODO: セキュリティリスクになりうるためバリデーションしたい + validator: $.str, + default: 'ja-JP' } }, }; export default define(meta, async (ps) => { + if (ps.endpoint.includes('.')) return null; + if (ps.lang.includes('.')) return null; const ep = endpoints.find(x => x.name === ps.endpoint); if (ep == null) return null; return { params: Object.entries(ep.meta.params || {}).map(([k, v]) => ({ name: k, type: v.validator.name === 'ID' ? 'String' : v.validator.name - })) + })), + schemas: schemas, + spec: genOpenapiSpecForEndpoint(ep, ps.lang) }; }); diff --git a/src/server/api/openapi/gen-spec.ts b/src/server/api/openapi/gen-spec.ts index 78e481037..ccf35f7c7 100644 --- a/src/server/api/openapi/gen-spec.ts +++ b/src/server/api/openapi/gen-spec.ts @@ -1,44 +1,14 @@ -import endpoints from '../endpoints'; +import endpoints, { IEndpoint } from '../endpoints'; import { Context } from 'cafy'; +import * as yaml from 'js-yaml'; +import * as fs from 'fs'; import config from '../../../config'; import { errors as basicErrors } from './errors'; import { schemas, convertSchemaToOpenApiSchema } from './schemas'; import { getDescription } from './description'; -export function genOpenapiSpec(lang = 'ja-JP') { - const spec = { - openapi: '3.0.0', - - info: { - version: 'v1', - title: 'Misskey API', - description: getDescription(lang), - 'x-logo': { url: '/assets/api-doc.png' } - }, - - externalDocs: { - description: 'Repository', - url: 'https://github.com/syuilo/misskey' - }, - - servers: [{ - url: config.apiUrl - }], - - paths: {} as any, - - components: { - schemas: schemas, - - securitySchemes: { - ApiKeyAuth: { - type: 'apiKey', - in: 'body', - name: 'i' - } - } - } - }; +export function genOpenapiSpecForEndpoint(endpoint: IEndpoint, lang = 'ja-JP') { + const locale = yaml.safeLoad(fs.readFileSync(__dirname + `/../../../api-docs/${lang}/` + endpoint.name + '.yml', 'utf-8')); function genProps(props: { [key: string]: Context; }) { const properties = {} as any; @@ -79,157 +49,195 @@ export function genOpenapiSpec(lang = 'ja-JP') { }; } - for (const endpoint of endpoints.filter(ep => !ep.meta.secure)) { - const porops = {} as any; - const errors = {} as any; + const porops = {} as any; + const errors = {} as any; - if (endpoint.meta.errors) { - for (const e of Object.values(endpoint.meta.errors)) { - errors[e.code] = { - value: { - error: e + if (endpoint.meta.errors) { + for (const e of Object.values(endpoint.meta.errors)) { + errors[e.code] = { + value: { + error: e + } + }; + } + } + + if (endpoint.meta.params) { + for (const [k, v] of Object.entries(endpoint.meta.params)) { + if (v.validator.data == null) v.validator.data = {}; + v.validator.data.desc = locale.params[k]; + if (v.deprecated) v.validator.data.deprecated = v.deprecated; + if (v.default) v.validator.data.default = v.default; + porops[k] = v.validator; + } + } + + const required = endpoint.meta.params ? Object.entries(endpoint.meta.params).filter(([k, v]) => !v.validator.isOptional).map(([k, v]) => k) : []; + + const resSchema = endpoint.meta.res ? convertSchemaToOpenApiSchema(endpoint.meta.res) : {}; + + let desc = (locale.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 info = { + operationId: endpoint.name, + summary: endpoint.name, + description: desc, + externalDocs: { + description: 'Source code', + url: `https://github.com/syuilo/misskey/blob/develop/src/server/api/endpoints/${endpoint.name}.ts` + }, + ...(endpoint.meta.tags ? { + tags: [endpoint.meta.tags[0]] + } : {}), + ...(endpoint.meta.requireCredential ? { + security: [{ + ApiKeyAuth: [] + }] + } : {}), + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + ...(required.length > 0 ? { required } : {}), + properties: endpoint.meta.params ? genProps(porops) : {} } - }; + } } - } - - if (endpoint.meta.params) { - for (const [k, v] of Object.entries(endpoint.meta.params)) { - if (v.validator.data == null) v.validator.data = {}; - if (v.desc) v.validator.data.desc = v.desc[lang]; - if (v.deprecated) v.validator.data.deprecated = v.deprecated; - if (v.default) v.validator.data.default = v.default; - porops[k] = v.validator; - } - } - - const required = endpoint.meta.params ? Object.entries(endpoint.meta.params).filter(([k, v]) => !v.validator.isOptional).map(([k, v]) => k) : []; - - const resSchema = endpoint.meta.res ? convertSchemaToOpenApiSchema(endpoint.meta.res) : {}; - - let desc = (endpoint.meta.desc ? endpoint.meta.desc[lang] : '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 info = { - operationId: endpoint.name, - summary: endpoint.name, - description: desc, - externalDocs: { - description: 'Source code', - url: `https://github.com/syuilo/misskey/blob/develop/src/server/api/endpoints/${endpoint.name}.ts` - }, - ...(endpoint.meta.tags ? { - tags: [endpoint.meta.tags[0]] - } : {}), - ...(endpoint.meta.requireCredential ? { - security: [{ - ApiKeyAuth: [] - }] - } : {}), - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - ...(required.length > 0 ? { required } : {}), - properties: endpoint.meta.params ? genProps(porops) : {} + }, + 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'] } + } + } }, - responses: { - ...(endpoint.meta.res ? { - '200': { - description: 'OK (with results)', - content: { - 'application/json': { - schema: resSchema - } - } + '401': { + description: 'Authentication error', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error' + }, + examples: basicErrors['401'] } - } : { - '204': { - description: 'OK (without any results)', + } + }, + '403': { + description: 'Forbiddon error', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error' + }, + examples: basicErrors['403'] } - }), - '400': { - description: 'Client error', + } + }, + '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: { ...errors, ...basicErrors['400'] } + examples: basicErrors['429'] } } - }, - '401': { - description: 'Authentication error', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/Error' - }, - examples: basicErrors['401'] - } + } + } : {}), + '500': { + description: 'Internal server error', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error' + }, + examples: basicErrors['500'] } - }, - '403': { - description: 'Forbiddon 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'] - } - } - }, - } - }; + } + }, + } + }; + return info; +} + +export function genOpenapiSpec(lang = 'ja-JP') { + const spec = { + openapi: '3.0.0', + + info: { + version: 'v1', + title: 'Misskey API', + description: getDescription(lang), + 'x-logo': { url: '/assets/api-doc.png' } + }, + + externalDocs: { + description: 'Repository', + url: 'https://github.com/syuilo/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)) { spec.paths['/' + endpoint.name] = { - post: info + post: genOpenapiSpecForEndpoint(endpoint, lang) }; }