fix(backend): 虚無ノートを投稿できる問題の修正と api.json
の OpenAPI Specification 3.1.0 への対応 (#12969)
* 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
This commit is contained in:
parent
79370aaee2
commit
d792f4f348
19 changed files with 259 additions and 146 deletions
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -60,6 +60,7 @@ export const paramDef = {
|
|||
'-firstRetrievedAt',
|
||||
'+latestRequestReceivedAt',
|
||||
'-latestRequestReceivedAt',
|
||||
null,
|
||||
],
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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')]),
|
||||
),
|
||||
};
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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<keyof OpenAPIV3.RequestBodyObject, unknown>;
|
||||
const { content } = value as Record<keyof OpenAPIV3_1.RequestBodyObject, unknown>;
|
||||
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<keyof OpenAPIV3.ResponseObject, unknown>;
|
||||
const { description } = value as Record<keyof OpenAPIV3_1.ResponseObject, unknown>;
|
||||
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);
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* version: 2023.12.2
|
||||
* generatedAt: 2024-01-11T14:29:04.814Z
|
||||
* generatedAt: 2024-01-13T04:31:38.778Z
|
||||
*/
|
||||
|
||||
import type {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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<string, unknown> | null;
|
||||
poll?: Record<string, never> | null;
|
||||
/**
|
||||
* Format: id
|
||||
* @example xxxxxxxxxx
|
||||
|
@ -3839,7 +3839,7 @@ export type components = {
|
|||
url?: string;
|
||||
reactionAndUserPairCache?: string[];
|
||||
clippedCount?: number;
|
||||
myReaction?: Record<string, unknown> | null;
|
||||
myReaction?: Record<string, never> | null;
|
||||
};
|
||||
NoteReaction: {
|
||||
/**
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue