From 344696912179edcb13623df5f7c9e8d8cd438031 Mon Sep 17 00:00:00 2001 From: syuilo Date: Sun, 24 Feb 2019 04:08:08 +0900 Subject: [PATCH] Refator: separate files --- src/server/api/gen-openapi-spec.ts | 576 -------------------------- src/server/api/openapi/description.ts | 58 +++ src/server/api/openapi/errors.ts | 69 +++ src/server/api/openapi/gen-spec.ts | 255 ++++++++++++ src/server/api/openapi/schemas.ts | 196 +++++++++ src/server/web/index.ts | 2 +- 6 files changed, 579 insertions(+), 577 deletions(-) delete mode 100644 src/server/api/gen-openapi-spec.ts create mode 100644 src/server/api/openapi/description.ts create mode 100644 src/server/api/openapi/errors.ts create mode 100644 src/server/api/openapi/gen-spec.ts create mode 100644 src/server/api/openapi/schemas.ts diff --git a/src/server/api/gen-openapi-spec.ts b/src/server/api/gen-openapi-spec.ts deleted file mode 100644 index 5c460d0dae..0000000000 --- a/src/server/api/gen-openapi-spec.ts +++ /dev/null @@ -1,576 +0,0 @@ -import endpoints from './endpoints'; -import { Context } from 'cafy'; -import config from '../../config'; - -const basicErrors = { - '400': { - 'INVALID_PARAM': { - value: { - error: { - message: 'Invalid param.', - code: 'INVALID_PARAM', - id: '3d81ceae-475f-4600-b2a8-2bc116157532', - } - } - } - }, - '401': { - 'CREDENTIAL_REQUIRED': { - value: { - error: { - message: 'Credential required.', - code: 'CREDENTIAL_REQUIRED', - id: '1384574d-a912-4b81-8601-c7b1c4085df1', - } - } - } - }, - '403': { - 'AUTHENTICATION_FAILED': { - value: { - error: { - message: 'Authentication failed. Please ensure your token is correct.', - code: 'AUTHENTICATION_FAILED', - id: 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14', - } - } - } - }, - '418': { - 'I_AM_AI': { - value: { - error: { - message: 'You sent a request to Ai-chan, Misskey\'s showgirl, instead of the server.', - code: 'I_AM_AI', - id: '60c46cd1-f23a-46b1-bebe-5d2b73951a84', - } - } - } - }, - '429': { - 'RATE_LIMIT_EXCEEDED': { - value: { - error: { - message: 'Rate limit exceeded. Please try again later.', - code: 'RATE_LIMIT_EXCEEDED', - id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef', - } - } - } - }, - '500': { - 'INTERNAL_ERROR': { - value: { - error: { - message: 'Internal error occurred. Please contact us if the error persists.', - code: 'INTERNAL_ERROR', - id: '5d37dbcb-891e-41ca-a3d6-e690c97775ac', - } - } - } - } -}; - -const schemas = { - Error: { - type: 'object', - properties: { - error: { - type: 'object', - description: 'An error object.', - properties: { - code: { - type: 'string', - description: 'An error code.', - }, - message: { - type: 'string', - description: 'An error message.', - }, - id: { - type: 'string', - format: 'uuid', - description: 'An error ID. This ID is static.', - } - }, - required: ['code', 'id', 'message'] - }, - }, - required: ['error'] - }, - - User: { - type: 'object', - properties: { - id: { - type: 'string', - format: 'id', - description: 'The unique identifier for this User.' - }, - username: { - type: 'string', - description: 'The screen name, handle, or alias that this user identifies themselves with.', - example: 'ai' - }, - name: { - type: 'string', - nullable: true, - description: 'The name of the user, as they’ve defined it.', - example: '藍' - }, - host: { - type: 'string', - nullable: true, - example: 'misskey.example.com' - }, - description: { - type: 'string', - nullable: true, - description: 'The user-defined UTF-8 string describing their account.', - example: 'Hi masters, I am Ai!' - }, - createdAt: { - type: 'string', - format: 'date-time', - description: 'The date that the user account was created on Misskey.' - }, - followersCount: { - type: 'number', - description: 'The number of followers this account currently has.' - }, - followingCount: { - type: 'number', - description: 'The number of users this account is following.' - }, - notesCount: { - type: 'number', - description: 'The number of Notes (including renotes) issued by the user.' - }, - isBot: { - type: 'boolean', - description: 'Whether this account is a bot.' - }, - isCat: { - type: 'boolean', - description: 'Whether this account is a cat.' - }, - isAdmin: { - type: 'boolean', - description: 'Whether this account is the admin.' - }, - isVerified: { - type: 'boolean' - }, - isLocked: { - type: 'boolean' - }, - }, - required: ['id', 'name', 'username', 'createdAt'] - }, - - Note: { - type: 'object', - properties: { - id: { - type: 'string', - format: 'id', - description: 'The unique identifier for this Note.' - }, - createdAt: { - type: 'string', - format: 'date-time', - description: 'The date that the Note was created on Misskey.' - }, - text: { - type: 'string' - }, - cw: { - type: 'string' - }, - userId: { - type: 'string', - format: 'id', - }, - user: { - $ref: '#/components/schemas/User' - }, - replyId: { - type: 'string', - format: 'id', - }, - renoteId: { - type: 'string', - format: 'id', - }, - reply: { - $ref: '#/components/schemas/Note' - }, - renote: { - $ref: '#/components/schemas/Note' - }, - viaMobile: { - type: 'boolean' - }, - visibility: { - type: 'string' - }, - }, - required: ['id', 'userId', 'createdAt'] - }, - - DriveFile: { - type: 'object', - properties: { - id: { - type: 'string', - format: 'id', - description: 'The unique identifier for this Drive file.' - }, - createdAt: { - type: 'string', - format: 'date-time', - description: 'The date that the Drive file was created on Misskey.' - }, - name: { - type: 'string', - description: 'The file name with extension.', - example: 'lenna.jpg' - }, - type: { - type: 'string', - description: 'The MIME type of this Drive file.', - example: 'image/jpeg' - }, - md5: { - type: 'string', - format: 'md5', - description: 'The MD5 hash of this Drive file.', - example: '15eca7fba0480996e2245f5185bf39f2' - }, - datasize: { - type: 'number', - description: 'The size of this Drive file. (bytes)', - example: 51469 - }, - folderId: { - type: 'string', - format: 'id', - nullable: true, - description: 'The parent folder ID of this Drive file.', - }, - isSensitive: { - type: 'boolean', - description: 'Whether this Drive file is sensitive.', - }, - }, - required: ['id', 'createdAt', 'name', 'type', 'datasize', 'md5'] - } -}; - -const desc = ` -## Usage -APIはすべてPOSTでリクエスト/レスポンスともにJSON形式です。 -一部のAPIは認証情報(アクセストークン)が必要です。リクエストの際に\`i\`というパラメータでアクセストークンを添付してください。 - -### アクセストークンを取得する -#### 自分のアカウントのアクセストークンを取得する -「設定 > API」で、自分のアクセストークンを取得できます。 - -> アカウントを不正利用される可能性があるため、このトークンは第三者に教えないでください(アプリなどにも入力しないでください)。 - -### アプリケーションとしてアクセストークンを取得する -直接ユーザーのアクセストークンをアプリケーションが扱うのは危険なので、 -アプリケーションからAPIを利用する際には、アプリケーションとアプリケーションを利用するユーザーが結び付けられた専用のアクセストークンをMisskeyに発行してもらいます。 - -#### 1.アプリケーションを登録する -まず、あなたのアプリケーションやWebサービス(以後、あなたのアプリと呼びます)をMisskeyに登録します。 -[デベロッパーセンター](/dev)にアクセスし、「アプリ > アプリ作成」からアプリを作成してください。 -フォームの記入欄の説明は以下の通りです: - -| 名前 | 説明 | -|---|---| -| アプリケーション名 | あなたのアプリの名称。 | -| アプリの概要 | あなたのアプリの簡単な説明や紹介。 | -| コールバックURL | ユーザーが後述する認証フォームで認証を終えた際にリダイレクトするURLを設定できます。あなたのアプリがWebサービスである場合に有用です。 | -| 権限 | あなたのアプリが要求する権限。ここで要求した機能だけがAPIからアクセスできます。 | - -登録が済むとあなたのアプリのシークレットキーが入手できます。このシークレットキーは後で使用します。 - -> アプリに成りすまされる可能性があるため、極力このシークレットキーは公開しないようにしてください。

- -#### 2.ユーザーに認証させる -アプリを使ってもらうには、ユーザーにアカウントへのアクセスの許可をもらう必要があります。 - -認証セッションを開始するには、%API_URL%/auth/session/generate へパラメータに appSecret としてシークレットキーを含めたリクエストを送信します。 -リクエスト形式はJSONで、メソッドはPOSTです。 -レスポンスとして認証セッションのトークンや認証フォームのURLが取得できるので、認証フォームのURLをブラウザで表示し、ユーザーにフォームを提示してください。 - -あなたのアプリがコールバックURLを設定している場合、 -ユーザーがあなたのアプリの連携を許可すると設定しているコールバックURLに token という名前でセッションのトークンが含まれたクエリを付けてリダイレクトします。 - -あなたのアプリがコールバックURLを設定していない場合、ユーザーがあなたのアプリの連携を許可したことを(何らかの方法で(たとえばボタンを押させるなど))確認出来るようにしてください。 - -#### 3.ユーザートークンを取得する -ユーザーが連携を許可したら、%API_URL%/auth/session/userkey へ次のパラメータを含むリクエストを送信します: - -| 名前 | 型 | 説明 | -|---|---|---| -| appSecret | string | アプリのシークレットキー | -| token | string | セッションのトークン | - -上手くいけば、認証したユーザーのユーザートークンがレスポンスとして取得できます。おめでとうございます! - -ユーザートークンが取得できたら、「ユーザーのユーザートークン+あなたのアプリのシークレットキーをsha256したもの」をアクセストークンとして、APIにリクエストできます。 - -アクセストークンの生成方法を擬似コードで表すと次のようになります: -
const i = sha256(userToken + secretKey);
-`; - -export function genOpenapiSpec(lang = 'ja-JP') { - const spec = { - openapi: '3.0.0', - - info: { - version: 'v1', - title: 'Misskey API', - description: '**Misskey is a decentralized microblogging platform.**\n\n' + desc, - 'x-logo': { url: '/assets/api-doc.png' } - }, - - externalDocs: { - description: 'Repository', - url: 'https://github.com/syuilo/misskey' - }, - - servers: [{ - url: config.api_url - }], - - paths: {} as any, - - components: { - schemas: schemas, - - securitySchemes: { - ApiKeyAuth: { - type: 'apiKey', - in: 'body', - name: 'i' - } - } - } - }; - - function genProps(props: { [key: string]: Context & { desc: any, default: any }; }) { - const properties = {} as any; - - const kvs = Object.entries(props); - - for (const kv of kvs) { - properties[kv[0]] = genProp(kv[1], kv[1].desc, kv[1].default); - } - - return properties; - } - - function genProp(param: Context, desc?: string, _default?: any): any { - const required = param.name === 'Object' ? (param as any).props ? Object.entries((param as any).props).filter(([k, v]: any) => !v.isOptional).map(([k, v]) => k) : [] : []; - return { - description: desc, - default: _default, - ...(_default ? { default: _default } : {}), - type: param.name === 'ID' ? 'string' : param.name.toLowerCase(), - ...(param.name === 'ID' ? { example: 'xxxxxxxxxxxxxxxxxxxxxxxx', format: 'id' } : {}), - nullable: param.isNullable, - ...(param.name === 'String' ? { - ...((param as any).enum ? { enum: (param as any).enum } : {}), - ...((param as any).minLength ? { minLength: (param as any).minLength } : {}), - ...((param as any).maxLength ? { maxLength: (param as any).maxLength } : {}), - } : {}), - ...(param.name === 'Number' ? { - ...((param as any).minimum ? { minimum: (param as any).minimum } : {}), - ...((param as any).maximum ? { maximum: (param as any).maximum } : {}), - } : {}), - ...(param.name === 'Object' ? { - ...(required.length > 0 ? { required } : {}), - properties: (param as any).props ? genProps((param as any).props) : {} - } : {}), - ...(param.name === 'Array' ? { - items: (param as any).ctx ? genProp((param as any).ctx) : {} - } : {}) - }; - } - - for (const endpoint of endpoints.filter(ep => !ep.meta.secure)) { - 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.params) { - for (const kv of Object.entries(endpoint.meta.params)) { - if (kv[1].desc) (kv[1].validator as any).desc = kv[1].desc[lang]; - if (kv[1].default) (kv[1].validator as any).default = kv[1].default; - porops[kv[0]] = kv[1].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 ? renderType(endpoint.meta.res) : {}; - - function renderType(x: any) { - const res = {} as any; - - if (['User', 'Note', 'DriveFile'].includes(x.type)) { - res['$ref'] = `#/components/schemas/${x.type}`; - } else if (x.type === 'object') { - res['type'] = 'object'; - if (x.props) { - const props = {} as any; - for (const kv of Object.entries(x.props)) { - props[kv[0]] = renderType(kv[1]); - } - res['properties'] = props; - } - } else if (x.type === 'array') { - res['type'] = 'array'; - if (x.items) { - res['items'] = renderType(x.items); - } - } else { - res['type'] = x.type; - } - - return res; - } - - const info = { - operationId: endpoint.name, - summary: endpoint.name, - description: endpoint.meta.desc ? endpoint.meta.desc[lang] : 'No description provided.', - 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 - } : {}), - ...(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'] } - } - } - }, - '401': { - description: 'Authentication error', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/Error' - }, - examples: basicErrors['401'] - } - } - }, - '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'] - } - } - }, - } - }; - - spec.paths['/' + endpoint.name] = { - post: info - }; - } - - return spec; -} diff --git a/src/server/api/openapi/description.ts b/src/server/api/openapi/description.ts new file mode 100644 index 0000000000..e32edf7b22 --- /dev/null +++ b/src/server/api/openapi/description.ts @@ -0,0 +1,58 @@ +export const description = ` +## Usage +APIはすべてPOSTでリクエスト/レスポンスともにJSON形式です。 +一部のAPIは認証情報(アクセストークン)が必要です。リクエストの際に\`i\`というパラメータでアクセストークンを添付してください。 + +### アクセストークンを取得する +#### 自分のアカウントのアクセストークンを取得する +「設定 > API」で、自分のアクセストークンを取得できます。 + +> アカウントを不正利用される可能性があるため、このトークンは第三者に教えないでください(アプリなどにも入力しないでください)。 + +### アプリケーションとしてアクセストークンを取得する +直接ユーザーのアクセストークンをアプリケーションが扱うのは危険なので、 +アプリケーションからAPIを利用する際には、アプリケーションとアプリケーションを利用するユーザーが結び付けられた専用のアクセストークンをMisskeyに発行してもらいます。 + +#### 1.アプリケーションを登録する +まず、あなたのアプリケーションやWebサービス(以後、あなたのアプリと呼びます)をMisskeyに登録します。 +[デベロッパーセンター](/dev)にアクセスし、「アプリ > アプリ作成」からアプリを作成してください。 +フォームの記入欄の説明は以下の通りです: + +| 名前 | 説明 | +|---|---| +| アプリケーション名 | あなたのアプリの名称。 | +| アプリの概要 | あなたのアプリの簡単な説明や紹介。 | +| コールバックURL | ユーザーが後述する認証フォームで認証を終えた際にリダイレクトするURLを設定できます。あなたのアプリがWebサービスである場合に有用です。 | +| 権限 | あなたのアプリが要求する権限。ここで要求した機能だけがAPIからアクセスできます。 | + +登録が済むとあなたのアプリのシークレットキーが入手できます。このシークレットキーは後で使用します。 + +> アプリに成りすまされる可能性があるため、極力このシークレットキーは公開しないようにしてください。

+ +#### 2.ユーザーに認証させる +アプリを使ってもらうには、ユーザーにアカウントへのアクセスの許可をもらう必要があります。 + +認証セッションを開始するには、%API_URL%/auth/session/generate へパラメータに appSecret としてシークレットキーを含めたリクエストを送信します。 +リクエスト形式はJSONで、メソッドはPOSTです。 +レスポンスとして認証セッションのトークンや認証フォームのURLが取得できるので、認証フォームのURLをブラウザで表示し、ユーザーにフォームを提示してください。 + +あなたのアプリがコールバックURLを設定している場合、 +ユーザーがあなたのアプリの連携を許可すると設定しているコールバックURLに token という名前でセッションのトークンが含まれたクエリを付けてリダイレクトします。 + +あなたのアプリがコールバックURLを設定していない場合、ユーザーがあなたのアプリの連携を許可したことを(何らかの方法で(たとえばボタンを押させるなど))確認出来るようにしてください。 + +#### 3.ユーザートークンを取得する +ユーザーが連携を許可したら、%API_URL%/auth/session/userkey へ次のパラメータを含むリクエストを送信します: + +| 名前 | 型 | 説明 | +|---|---|---| +| appSecret | string | アプリのシークレットキー | +| token | string | セッションのトークン | + +上手くいけば、認証したユーザーのユーザートークンがレスポンスとして取得できます。おめでとうございます! + +ユーザートークンが取得できたら、「ユーザーのユーザートークン+あなたのアプリのシークレットキーをsha256したもの」をアクセストークンとして、APIにリクエストできます。 + +アクセストークンの生成方法を擬似コードで表すと次のようになります: +
const i = sha256(userToken + secretKey);
+`; diff --git a/src/server/api/openapi/errors.ts b/src/server/api/openapi/errors.ts new file mode 100644 index 0000000000..43bcc323ba --- /dev/null +++ b/src/server/api/openapi/errors.ts @@ -0,0 +1,69 @@ + +export const errors = { + '400': { + 'INVALID_PARAM': { + value: { + error: { + message: 'Invalid param.', + code: 'INVALID_PARAM', + id: '3d81ceae-475f-4600-b2a8-2bc116157532', + } + } + } + }, + '401': { + 'CREDENTIAL_REQUIRED': { + value: { + error: { + message: 'Credential required.', + code: 'CREDENTIAL_REQUIRED', + id: '1384574d-a912-4b81-8601-c7b1c4085df1', + } + } + } + }, + '403': { + 'AUTHENTICATION_FAILED': { + value: { + error: { + message: 'Authentication failed. Please ensure your token is correct.', + code: 'AUTHENTICATION_FAILED', + id: 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14', + } + } + } + }, + '418': { + 'I_AM_AI': { + value: { + error: { + message: 'You sent a request to Ai-chan, Misskey\'s showgirl, instead of the server.', + code: 'I_AM_AI', + id: '60c46cd1-f23a-46b1-bebe-5d2b73951a84', + } + } + } + }, + '429': { + 'RATE_LIMIT_EXCEEDED': { + value: { + error: { + message: 'Rate limit exceeded. Please try again later.', + code: 'RATE_LIMIT_EXCEEDED', + id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef', + } + } + } + }, + '500': { + 'INTERNAL_ERROR': { + value: { + error: { + message: 'Internal error occurred. Please contact us if the error persists.', + code: 'INTERNAL_ERROR', + id: '5d37dbcb-891e-41ca-a3d6-e690c97775ac', + } + } + } + } +}; diff --git a/src/server/api/openapi/gen-spec.ts b/src/server/api/openapi/gen-spec.ts new file mode 100644 index 0000000000..ad46eb20a4 --- /dev/null +++ b/src/server/api/openapi/gen-spec.ts @@ -0,0 +1,255 @@ +import endpoints from '../endpoints'; +import { Context } from 'cafy'; +import config from '../../../config'; +import { errors as basicErrors } from './errors'; +import { schemas } from './schemas'; +import { description } from './description'; + +export function genOpenapiSpec(lang = 'ja-JP') { + const spec = { + openapi: '3.0.0', + + info: { + version: 'v1', + title: 'Misskey API', + description: '**Misskey is a decentralized microblogging platform.**\n\n' + description, + 'x-logo': { url: '/assets/api-doc.png' } + }, + + externalDocs: { + description: 'Repository', + url: 'https://github.com/syuilo/misskey' + }, + + servers: [{ + url: config.api_url + }], + + paths: {} as any, + + components: { + schemas: schemas, + + securitySchemes: { + ApiKeyAuth: { + type: 'apiKey', + in: 'body', + name: 'i' + } + } + } + }; + + function genProps(props: { [key: string]: Context & { desc: any, default: any }; }) { + const properties = {} as any; + + const kvs = Object.entries(props); + + for (const kv of kvs) { + properties[kv[0]] = genProp(kv[1], kv[1].desc, kv[1].default); + } + + return properties; + } + + function genProp(param: Context, desc?: string, _default?: any): any { + const required = param.name === 'Object' ? (param as any).props ? Object.entries((param as any).props).filter(([k, v]: any) => !v.isOptional).map(([k, v]) => k) : [] : []; + return { + description: desc, + default: _default, + ...(_default ? { default: _default } : {}), + type: param.name === 'ID' ? 'string' : param.name.toLowerCase(), + ...(param.name === 'ID' ? { example: 'xxxxxxxxxxxxxxxxxxxxxxxx', format: 'id' } : {}), + nullable: param.isNullable, + ...(param.name === 'String' ? { + ...((param as any).enum ? { enum: (param as any).enum } : {}), + ...((param as any).minLength ? { minLength: (param as any).minLength } : {}), + ...((param as any).maxLength ? { maxLength: (param as any).maxLength } : {}), + } : {}), + ...(param.name === 'Number' ? { + ...((param as any).minimum ? { minimum: (param as any).minimum } : {}), + ...((param as any).maximum ? { maximum: (param as any).maximum } : {}), + } : {}), + ...(param.name === 'Object' ? { + ...(required.length > 0 ? { required } : {}), + properties: (param as any).props ? genProps((param as any).props) : {} + } : {}), + ...(param.name === 'Array' ? { + items: (param as any).ctx ? genProp((param as any).ctx) : {} + } : {}) + }; + } + + for (const endpoint of endpoints.filter(ep => !ep.meta.secure)) { + 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.params) { + for (const kv of Object.entries(endpoint.meta.params)) { + if (kv[1].desc) (kv[1].validator as any).desc = kv[1].desc[lang]; + if (kv[1].default) (kv[1].validator as any).default = kv[1].default; + porops[kv[0]] = kv[1].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 ? renderType(endpoint.meta.res) : {}; + + function renderType(x: any) { + const res = {} as any; + + if (['User', 'Note', 'DriveFile'].includes(x.type)) { + res['$ref'] = `#/components/schemas/${x.type}`; + } else if (x.type === 'object') { + res['type'] = 'object'; + if (x.props) { + const props = {} as any; + for (const kv of Object.entries(x.props)) { + props[kv[0]] = renderType(kv[1]); + } + res['properties'] = props; + } + } else if (x.type === 'array') { + res['type'] = 'array'; + if (x.items) { + res['items'] = renderType(x.items); + } + } else { + res['type'] = x.type; + } + + return res; + } + + const info = { + operationId: endpoint.name, + summary: endpoint.name, + description: endpoint.meta.desc ? endpoint.meta.desc[lang] : 'No description provided.', + 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 + } : {}), + ...(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'] } + } + } + }, + '401': { + description: 'Authentication error', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error' + }, + examples: basicErrors['401'] + } + } + }, + '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'] + } + } + }, + } + }; + + spec.paths['/' + endpoint.name] = { + post: info + }; + } + + return spec; +} diff --git a/src/server/api/openapi/schemas.ts b/src/server/api/openapi/schemas.ts new file mode 100644 index 0000000000..3de9e42e0c --- /dev/null +++ b/src/server/api/openapi/schemas.ts @@ -0,0 +1,196 @@ + +export const schemas = { + Error: { + type: 'object', + properties: { + error: { + type: 'object', + description: 'An error object.', + properties: { + code: { + type: 'string', + description: 'An error code.', + }, + message: { + type: 'string', + description: 'An error message.', + }, + id: { + type: 'string', + format: 'uuid', + description: 'An error ID. This ID is static.', + } + }, + required: ['code', 'id', 'message'] + }, + }, + required: ['error'] + }, + + User: { + type: 'object', + properties: { + id: { + type: 'string', + format: 'id', + description: 'The unique identifier for this User.' + }, + username: { + type: 'string', + description: 'The screen name, handle, or alias that this user identifies themselves with.', + example: 'ai' + }, + name: { + type: 'string', + nullable: true, + description: 'The name of the user, as they’ve defined it.', + example: '藍' + }, + host: { + type: 'string', + nullable: true, + example: 'misskey.example.com' + }, + description: { + type: 'string', + nullable: true, + description: 'The user-defined UTF-8 string describing their account.', + example: 'Hi masters, I am Ai!' + }, + createdAt: { + type: 'string', + format: 'date-time', + description: 'The date that the user account was created on Misskey.' + }, + followersCount: { + type: 'number', + description: 'The number of followers this account currently has.' + }, + followingCount: { + type: 'number', + description: 'The number of users this account is following.' + }, + notesCount: { + type: 'number', + description: 'The number of Notes (including renotes) issued by the user.' + }, + isBot: { + type: 'boolean', + description: 'Whether this account is a bot.' + }, + isCat: { + type: 'boolean', + description: 'Whether this account is a cat.' + }, + isAdmin: { + type: 'boolean', + description: 'Whether this account is the admin.' + }, + isVerified: { + type: 'boolean' + }, + isLocked: { + type: 'boolean' + }, + }, + required: ['id', 'name', 'username', 'createdAt'] + }, + + Note: { + type: 'object', + properties: { + id: { + type: 'string', + format: 'id', + description: 'The unique identifier for this Note.' + }, + createdAt: { + type: 'string', + format: 'date-time', + description: 'The date that the Note was created on Misskey.' + }, + text: { + type: 'string' + }, + cw: { + type: 'string' + }, + userId: { + type: 'string', + format: 'id', + }, + user: { + $ref: '#/components/schemas/User' + }, + replyId: { + type: 'string', + format: 'id', + }, + renoteId: { + type: 'string', + format: 'id', + }, + reply: { + $ref: '#/components/schemas/Note' + }, + renote: { + $ref: '#/components/schemas/Note' + }, + viaMobile: { + type: 'boolean' + }, + visibility: { + type: 'string' + }, + }, + required: ['id', 'userId', 'createdAt'] + }, + + DriveFile: { + type: 'object', + properties: { + id: { + type: 'string', + format: 'id', + description: 'The unique identifier for this Drive file.' + }, + createdAt: { + type: 'string', + format: 'date-time', + description: 'The date that the Drive file was created on Misskey.' + }, + name: { + type: 'string', + description: 'The file name with extension.', + example: 'lenna.jpg' + }, + type: { + type: 'string', + description: 'The MIME type of this Drive file.', + example: 'image/jpeg' + }, + md5: { + type: 'string', + format: 'md5', + description: 'The MD5 hash of this Drive file.', + example: '15eca7fba0480996e2245f5185bf39f2' + }, + datasize: { + type: 'number', + description: 'The size of this Drive file. (bytes)', + example: 51469 + }, + folderId: { + type: 'string', + format: 'id', + nullable: true, + description: 'The parent folder ID of this Drive file.', + }, + isSensitive: { + type: 'boolean', + description: 'Whether this Drive file is sensitive.', + }, + }, + required: ['id', 'createdAt', 'name', 'type', 'datasize', 'md5'] + } +}; diff --git a/src/server/web/index.ts b/src/server/web/index.ts index 0270dde612..61878d880b 100644 --- a/src/server/web/index.ts +++ b/src/server/web/index.ts @@ -21,7 +21,7 @@ import getNoteSummary from '../../misc/get-note-summary'; import fetchMeta from '../../misc/fetch-meta'; import Emoji from '../../models/emoji'; import * as pkg from '../../../package.json'; -import { genOpenapiSpec } from '../api/gen-openapi-spec'; +import { genOpenapiSpec } from '../api/openapi/gen-spec'; const client = `${__dirname}/../../client/`;