From d792f4f34886718aec3c07c84ea15d4ea8cf7a2c Mon Sep 17 00:00:00 2001 From: zyoshoka <107108195+zyoshoka@users.noreply.github.com> Date: Sat, 13 Jan 2024 16:54:25 +0900 Subject: fix(backend): 虚無ノートを投稿できる問題の修正と `api.json` の OpenAPI Specification 3.1.0 への対応 (#12969) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(backend): `text: null`だけのノートは投稿できないように * add test * Update CHANGELOG.md * chore: bump OpenAPI Specification from 3.0.0 to 3.1.0 * chore: テストがすでにコメントで記述されていたのでそっちを使うことにする * fix test * fix(backend): prohibit posting whitespace-only notes * Update CHANGELOG.md * fix(backend): `renoteId`または`fileIds`(`mediaIds`)または`poll`が`null`でない場合に、`text が空白文字のみで構成されたリクエストになることを許可して、結果は`text: null`を返すように * test(backend): 引用renoteで空白文字のみで構成されたtextにするとレスポンスが`text: null`になることをチェックするテストを追加 * fix(frontend): `text`が`null`であって`renoteId`と`replyId`が`null`でないようなノートは引用リノートとして表示するように * fix(misskey-js): OpenAPI 3.1に対応 * fix(misskey-js): 型生成をOpenAPI Specification 3.1.0に対応 * fix(ci): `validate-api.json`をOpenAPI Specification 3.1.0に対応 * fix(ci): スキーマ書き換えの際のミスを修正 * Revert "fix(frontend): `text`が`null`であって`renoteId`と`replyId`が`null`でないようなノートは引用リノートとして表示するように" This reverts commit a9ca55343df6ea1679599acbc4801f78aa3a242b. * fix(misskey-js): `build-misskey-js-with-types`時は`api.json`のGETをスキップするように * Revert "fix(misskey-js): `build-misskey-js-with-types`時は`api.json`のGETをスキップするように" This reverts commit 865458989f9ddacc38d1bb3743a41ea828dbf324. * fix(misskey-js): `openapi-parser`で`validate`のかわりに`parse`を用いるように * Update CHANGELOG.md --- packages/backend/src/core/NoteCreateService.ts | 3 ++ .../src/server/api/endpoints/drive/files.ts | 2 +- .../server/api/endpoints/federation/instances.ts | 1 + .../src/server/api/endpoints/i/2fa/register-key.ts | 33 +++++++++---------- .../src/server/api/endpoints/notes/create.test.ts | 14 +++++--- .../src/server/api/endpoints/notes/create.ts | 34 ++++++++++++++++---- .../backend/src/server/api/openapi/gen-spec.ts | 10 +++--- packages/backend/src/server/api/openapi/schemas.ts | 37 ++++++++++++++-------- packages/backend/test/e2e/note.ts | 13 ++++++++ packages/misskey-js/generator/package.json | 4 +-- packages/misskey-js/generator/src/generator.ts | 28 ++++++++-------- packages/misskey-js/src/autogen/apiClientJSDoc.ts | 2 +- packages/misskey-js/src/autogen/endpoint.ts | 2 +- packages/misskey-js/src/autogen/entities.ts | 2 +- packages/misskey-js/src/autogen/models.ts | 2 +- packages/misskey-js/src/autogen/types.ts | 6 ++-- 16 files changed, 124 insertions(+), 69 deletions(-) (limited to 'packages') diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 97fb80ab39..30f6d07118 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -325,6 +325,9 @@ export class NoteCreateService implements OnApplicationShutdown { data.text = data.text.slice(0, DB_MAX_NOTE_TEXT_LENGTH); } data.text = data.text.trim(); + if (data.text === '') { + data.text = null; + } } else { data.text = null; } diff --git a/packages/backend/src/server/api/endpoints/drive/files.ts b/packages/backend/src/server/api/endpoints/drive/files.ts index b7e9d12e94..0ca31dc993 100644 --- a/packages/backend/src/server/api/endpoints/drive/files.ts +++ b/packages/backend/src/server/api/endpoints/drive/files.ts @@ -36,7 +36,7 @@ export const paramDef = { untilId: { type: 'string', format: 'misskey:id' }, folderId: { type: 'string', format: 'misskey:id', nullable: true, default: null }, type: { type: 'string', nullable: true, pattern: /^[a-zA-Z\/\-*]+$/.toString().slice(1, -1) }, - sort: { type: 'string', nullable: true, enum: ['+createdAt', '-createdAt', '+name', '-name', '+size', '-size'] }, + sort: { type: 'string', nullable: true, enum: ['+createdAt', '-createdAt', '+name', '-name', '+size', '-size', null] }, }, required: [], } as const; diff --git a/packages/backend/src/server/api/endpoints/federation/instances.ts b/packages/backend/src/server/api/endpoints/federation/instances.ts index e5a90715f5..457309731f 100644 --- a/packages/backend/src/server/api/endpoints/federation/instances.ts +++ b/packages/backend/src/server/api/endpoints/federation/instances.ts @@ -60,6 +60,7 @@ export const paramDef = { '-firstRetrievedAt', '+latestRequestReceivedAt', '-latestRequestReceivedAt', + null, ], }, }, diff --git a/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts index 0fac96d58f..15c5011db9 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts @@ -103,13 +103,13 @@ export const meta = { items: { type: 'string', enum: [ - "ble", - "cable", - "hybrid", - "internal", - "nfc", - "smart-card", - "usb", + 'ble', + 'cable', + 'hybrid', + 'internal', + 'nfc', + 'smart-card', + 'usb', ], }, }, @@ -123,8 +123,8 @@ export const meta = { authenticatorAttachment: { type: 'string', enum: [ - "cross-platform", - "platform", + 'cross-platform', + 'platform', ], }, requireResidentKey: { @@ -133,9 +133,9 @@ export const meta = { userVerification: { type: 'string', enum: [ - "discouraged", - "preferred", - "required", + 'discouraged', + 'preferred', + 'required', ], }, }, @@ -144,10 +144,11 @@ export const meta = { type: 'string', nullable: true, enum: [ - "direct", - "enterprise", - "indirect", - "none", + 'direct', + 'enterprise', + 'indirect', + 'none', + null, ], }, extensions: { diff --git a/packages/backend/src/server/api/endpoints/notes/create.test.ts b/packages/backend/src/server/api/endpoints/notes/create.test.ts index 6086f99c92..3228bbd014 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.test.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.test.ts @@ -34,11 +34,10 @@ describe('api:notes/create', () => { .toBe(VALID); }); - // TODO - //test('null post', () => { - // expect(v({ text: null })) - // .toBe(INVALID); - //}); + test('null post', () => { + expect(v({ text: null })) + .toBe(INVALID); + }); test('0 characters post', () => { expect(v({ text: '' })) @@ -49,6 +48,11 @@ describe('api:notes/create', () => { expect(v({ text: await tooLong })) .toBe(INVALID); }); + + test('whitespace-only post', () => { + expect(v({ text: ' ' })) + .toBe(INVALID); + }); }); describe('cw', () => { diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts index c5d42dabe4..29a0f7418c 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -172,13 +172,33 @@ export const paramDef = { }, }, // (re)note with text, files and poll are optional - anyOf: [ - { required: ['text'] }, - { required: ['renoteId'] }, - { required: ['fileIds'] }, - { required: ['mediaIds'] }, - { required: ['poll'] }, - ], + if: { + properties: { + renoteId: { + type: 'null', + }, + fileIds: { + type: 'null', + }, + mediaIds: { + type: 'null', + }, + poll: { + type: 'null', + }, + }, + }, + then: { + properties: { + text: { + type: 'string', + minLength: 1, + maxLength: MAX_NOTE_TEXT_LENGTH, + pattern: '[^\\s]+', + }, + }, + required: ['text'], + }, } as const; @Injectable() diff --git a/packages/backend/src/server/api/openapi/gen-spec.ts b/packages/backend/src/server/api/openapi/gen-spec.ts index 0e71510b48..971a6116bf 100644 --- a/packages/backend/src/server/api/openapi/gen-spec.ts +++ b/packages/backend/src/server/api/openapi/gen-spec.ts @@ -10,7 +10,7 @@ import { schemas, convertSchemaToOpenApiSchema } from './schemas.js'; export function genOpenapiSpec(config: Config) { const spec = { - openapi: '3.0.0', + openapi: '3.1.0', info: { version: config.version, @@ -56,7 +56,7 @@ export function genOpenapiSpec(config: Config) { } } - const resSchema = endpoint.meta.res ? convertSchemaToOpenApiSchema(endpoint.meta.res) : {}; + const resSchema = endpoint.meta.res ? convertSchemaToOpenApiSchema(endpoint.meta.res, 'res') : {}; let desc = (endpoint.meta.description ? endpoint.meta.description : 'No description provided.') + '\n\n'; @@ -71,7 +71,7 @@ export function genOpenapiSpec(config: Config) { } const requestType = endpoint.meta.requireFile ? 'multipart/form-data' : 'application/json'; - const schema = { ...endpoint.params }; + const schema = { ...convertSchemaToOpenApiSchema(endpoint.params, 'param') }; if (endpoint.meta.requireFile) { schema.properties = { @@ -210,7 +210,9 @@ export function genOpenapiSpec(config: Config) { }; spec.paths['/' + endpoint.name] = { - ...(endpoint.meta.allowGet ? { get: info } : {}), + ...(endpoint.meta.allowGet ? { + get: info, + } : {}), post: info, }; } diff --git a/packages/backend/src/server/api/openapi/schemas.ts b/packages/backend/src/server/api/openapi/schemas.ts index 2716f5f162..a862a7b742 100644 --- a/packages/backend/src/server/api/openapi/schemas.ts +++ b/packages/backend/src/server/api/openapi/schemas.ts @@ -6,32 +6,35 @@ import type { Schema } from '@/misc/json-schema.js'; import { refs } from '@/misc/json-schema.js'; -export function convertSchemaToOpenApiSchema(schema: Schema) { - // optional, refはスキーマ定義に含まれないので分離しておく +export function convertSchemaToOpenApiSchema(schema: Schema, type: 'param' | 'res') { + // optional, nullable, refはスキーマ定義に含まれないので分離しておく // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { optional, ref, ...res }: any = schema; + const { optional, nullable, ref, ...res }: any = schema; if (schema.type === 'object' && schema.properties) { - const required = Object.entries(schema.properties).filter(([k, v]) => !v.optional).map(([k]) => k); - if (required.length > 0) { + if (type === 'res') { + const required = Object.entries(schema.properties).filter(([k, v]) => !v.optional).map(([k]) => k); + if (required.length > 0) { // 空配列は許可されない - res.required = required; + res.required = required; + } } for (const k of Object.keys(schema.properties)) { - res.properties[k] = convertSchemaToOpenApiSchema(schema.properties[k]); + res.properties[k] = convertSchemaToOpenApiSchema(schema.properties[k], type); } } if (schema.type === 'array' && schema.items) { - res.items = convertSchemaToOpenApiSchema(schema.items); + res.items = convertSchemaToOpenApiSchema(schema.items, type); } - if (schema.anyOf) res.anyOf = schema.anyOf.map(convertSchemaToOpenApiSchema); - if (schema.oneOf) res.oneOf = schema.oneOf.map(convertSchemaToOpenApiSchema); - if (schema.allOf) res.allOf = schema.allOf.map(convertSchemaToOpenApiSchema); + for (const o of ['anyOf', 'oneOf', 'allOf'] as const) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + if (o in schema) res[o] = schema[o]!.map(schema => convertSchemaToOpenApiSchema(schema, type)); + } - if (schema.ref) { + if (type === 'res' && schema.ref) { const $ref = `#/components/schemas/${schema.ref}`; if (schema.nullable || schema.optional) { res.allOf = [{ $ref }]; @@ -40,6 +43,14 @@ export function convertSchemaToOpenApiSchema(schema: Schema) { } } + if (schema.nullable) { + if (Array.isArray(schema.type) && !schema.type.includes('null')) { + res.type.push('null'); + } else if (typeof schema.type === 'string') { + res.type = [res.type, 'null']; + } + } + return res; } @@ -72,6 +83,6 @@ export const schemas = { }, ...Object.fromEntries( - Object.entries(refs).map(([key, schema]) => [key, convertSchemaToOpenApiSchema(schema)]), + Object.entries(refs).map(([key, schema]) => [key, convertSchemaToOpenApiSchema(schema, 'res')]), ), }; diff --git a/packages/backend/test/e2e/note.ts b/packages/backend/test/e2e/note.ts index 0f2e08e675..0280b051f5 100644 --- a/packages/backend/test/e2e/note.ts +++ b/packages/backend/test/e2e/note.ts @@ -136,6 +136,19 @@ describe('Note', () => { assert.strictEqual(res.body.createdNote.renote.text, bobPost.text); }); + test('引用renoteで空白文字のみで構成されたtextにするとレスポンスがtext: nullになる', async () => { + const bobPost = await post(bob, { + text: 'test', + }); + const res = await api('/notes/create', { + text: ' ', + renoteId: bobPost.id, + }, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(res.body.createdNote.text, null); + }); + test('visibility: followersでrenoteできる', async () => { const createRes = await api('/notes/create', { text: 'test', diff --git a/packages/misskey-js/generator/package.json b/packages/misskey-js/generator/package.json index 9c15965b12..a1c0f41cb2 100644 --- a/packages/misskey-js/generator/package.json +++ b/packages/misskey-js/generator/package.json @@ -7,14 +7,14 @@ "generate": "tsx src/generator.ts && eslint ./built/**/* --ext .ts --fix" }, "devDependencies": { - "@apidevtools/swagger-parser": "10.1.0", "@misskey-dev/eslint-plugin": "^1.0.0", + "@readme/openapi-parser": "2.5.0", "@types/node": "20.9.1", "@typescript-eslint/eslint-plugin": "6.11.0", "@typescript-eslint/parser": "6.11.0", "eslint": "8.53.0", "openapi-types": "12.1.3", - "openapi-typescript": "6.7.1", + "openapi-typescript": "6.7.3", "ts-case-convert": "2.0.2", "tsx": "4.4.0", "typescript": "5.3.3" diff --git a/packages/misskey-js/generator/src/generator.ts b/packages/misskey-js/generator/src/generator.ts index f12ed94513..7e72359167 100644 --- a/packages/misskey-js/generator/src/generator.ts +++ b/packages/misskey-js/generator/src/generator.ts @@ -1,10 +1,10 @@ import { mkdir, writeFile } from 'fs/promises'; -import { OpenAPIV3 } from 'openapi-types'; +import { OpenAPIV3_1 } from 'openapi-types'; import { toPascal } from 'ts-case-convert'; -import SwaggerParser from '@apidevtools/swagger-parser'; +import OpenAPIParser from '@readme/openapi-parser'; import openapiTS from 'openapi-typescript'; -function generateVersionHeaderComment(openApiDocs: OpenAPIV3.Document): string { +function generateVersionHeaderComment(openApiDocs: OpenAPIV3_1.Document): string { const contents = { version: openApiDocs.info.version, generatedAt: new Date().toISOString(), @@ -21,7 +21,7 @@ function generateVersionHeaderComment(openApiDocs: OpenAPIV3.Document): string { } async function generateBaseTypes( - openApiDocs: OpenAPIV3.Document, + openApiDocs: OpenAPIV3_1.Document, openApiJsonPath: string, typeFileName: string, ) { @@ -47,7 +47,7 @@ async function generateBaseTypes( } async function generateSchemaEntities( - openApiDocs: OpenAPIV3.Document, + openApiDocs: OpenAPIV3_1.Document, typeFileName: string, outputPath: string, ) { @@ -71,7 +71,7 @@ async function generateSchemaEntities( } async function generateEndpoints( - openApiDocs: OpenAPIV3.Document, + openApiDocs: OpenAPIV3_1.Document, typeFileName: string, entitiesOutputPath: string, endpointOutputPath: string, @@ -79,7 +79,7 @@ async function generateEndpoints( const endpoints: Endpoint[] = []; // misskey-jsはPOST固定で送っているので、こちらも決め打ちする。別メソッドに対応することがあればこちらも直す必要あり - const paths = openApiDocs.paths; + const paths = openApiDocs.paths ?? {}; const postPathItems = Object.keys(paths) .map(it => paths[it]?.post) .filter(filterUndefined); @@ -160,7 +160,7 @@ async function generateEndpoints( } async function generateApiClientJSDoc( - openApiDocs: OpenAPIV3.Document, + openApiDocs: OpenAPIV3_1.Document, apiClientFileName: string, endpointsFileName: string, warningsOutputPath: string, @@ -168,7 +168,7 @@ async function generateApiClientJSDoc( const endpoints: { operationId: string; description: string; }[] = []; // misskey-jsはPOST固定で送っているので、こちらも決め打ちする。別メソッドに対応することがあればこちらも直す必要あり - const paths = openApiDocs.paths; + const paths = openApiDocs.paths ?? {}; const postPathItems = Object.keys(paths) .map(it => paths[it]?.post) .filter(filterUndefined); @@ -221,21 +221,21 @@ async function generateApiClientJSDoc( await writeFile(warningsOutputPath, endpointOutputLine.join('\n')); } -function isRequestBodyObject(value: unknown): value is OpenAPIV3.RequestBodyObject { +function isRequestBodyObject(value: unknown): value is OpenAPIV3_1.RequestBodyObject { if (!value) { return false; } - const { content } = value as Record; + const { content } = value as Record; return content !== undefined; } -function isResponseObject(value: unknown): value is OpenAPIV3.ResponseObject { +function isResponseObject(value: unknown): value is OpenAPIV3_1.ResponseObject { if (!value) { return false; } - const { description } = value as Record; + const { description } = value as Record; return description !== undefined; } @@ -330,7 +330,7 @@ async function main() { await mkdir(generatePath, { recursive: true }); const openApiJsonPath = './api.json'; - const openApiDocs = await SwaggerParser.validate(openApiJsonPath) as OpenAPIV3.Document; + const openApiDocs = await OpenAPIParser.parse(openApiJsonPath) as OpenAPIV3_1.Document; const typeFileName = './built/autogen/types.ts'; await generateBaseTypes(openApiDocs, openApiJsonPath, typeFileName); diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts index 34fe50e948..b60f449a71 100644 --- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts +++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts @@ -1,6 +1,6 @@ /* * version: 2023.12.2 - * generatedAt: 2024-01-11T14:29:04.817Z + * generatedAt: 2024-01-13T04:31:38.782Z */ import type { SwitchCaseResponseType } from '../api.js'; diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts index 8208c200dc..dc591a7046 100644 --- a/packages/misskey-js/src/autogen/endpoint.ts +++ b/packages/misskey-js/src/autogen/endpoint.ts @@ -1,6 +1,6 @@ /* * version: 2023.12.2 - * generatedAt: 2024-01-11T14:29:04.814Z + * generatedAt: 2024-01-13T04:31:38.778Z */ import type { diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts index 5bac5ac270..dfe24ce0d8 100644 --- a/packages/misskey-js/src/autogen/entities.ts +++ b/packages/misskey-js/src/autogen/entities.ts @@ -1,6 +1,6 @@ /* * version: 2023.12.2 - * generatedAt: 2024-01-11T14:29:04.811Z + * generatedAt: 2024-01-13T04:31:38.775Z */ import { operations } from './types.js'; diff --git a/packages/misskey-js/src/autogen/models.ts b/packages/misskey-js/src/autogen/models.ts index 518f6a8635..5c6bebf2fd 100644 --- a/packages/misskey-js/src/autogen/models.ts +++ b/packages/misskey-js/src/autogen/models.ts @@ -1,6 +1,6 @@ /* * version: 2023.12.2 - * generatedAt: 2024-01-11T14:29:04.810Z + * generatedAt: 2024-01-13T04:31:38.773Z */ import { components } from './types.js'; diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index d0aa240907..76e2b5309c 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -3,7 +3,7 @@ /* * version: 2023.12.2 - * generatedAt: 2024-01-11T14:29:04.681Z + * generatedAt: 2024-01-13T04:31:38.633Z */ /** @@ -3816,7 +3816,7 @@ export type components = { fileIds?: string[]; files?: components['schemas']['DriveFile'][]; tags?: string[]; - poll?: Record | null; + poll?: Record | null; /** * Format: id * @example xxxxxxxxxx @@ -3839,7 +3839,7 @@ export type components = { url?: string; reactionAndUserPairCache?: string[]; clippedCount?: number; - myReaction?: Record | null; + myReaction?: Record | null; }; NoteReaction: { /** -- cgit v1.2.3-freya