fix(backend): correct invalid schema format specifying only `required` for `anyOf` (#16089)

* fix(backend): correct invalid schema format specifying only `required` for `anyOf`

* refactor(backend): make types derived from `allOf` or `anyOf` more strong
This commit is contained in:
zyoshoka 2025-05-27 08:57:09 +09:00 committed by GitHub
parent ed3a844f5d
commit d27075c5f5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 394 additions and 252 deletions

View File

@ -581,27 +581,6 @@ pnpm dlx typeorm migration:generate -d ormconfig.js -o <migration name>
- 生成後、ファイルをmigration下に移してください - 生成後、ファイルをmigration下に移してください
- 作成されたスクリプトは不必要な変更を含むため除去してください - 作成されたスクリプトは不必要な変更を含むため除去してください
### JSON SchemaのobjectでanyOfを使うとき
JSON Schemaで、objectに対してanyOfを使う場合、anyOfの中でpropertiesを定義しないこと。
バリデーションが効かないため。SchemaTypeもそのように作られており、objectのanyOf内のpropertiesは捨てられます
https://github.com/misskey-dev/misskey/pull/10082
テキストhogeおよびfugaについて、片方を必須としつつ両方の指定もありうる場合:
```ts
export const paramDef = {
type: 'object',
properties: {
hoge: { type: 'string', minLength: 1 },
fuga: { type: 'string', minLength: 1 },
},
anyOf: [
{ required: ['hoge'] },
{ required: ['fuga'] },
],
} as const;
```
### コネクションには`markRaw`せよ ### コネクションには`markRaw`せよ
**Vueのコンポーネントのdataオプションとして**misskey.jsのコネクションを設定するとき、必ず`markRaw`でラップしてください。インスタンスが不必要にリアクティブ化されることで、misskey.js内の処理で不具合が発生するとともに、パフォーマンス上の問題にも繋がる。なお、Composition APIを使う場合はこの限りではない(リアクティブ化はマニュアルなため)。 **Vueのコンポーネントのdataオプションとして**misskey.jsのコネクションを設定するとき、必ず`markRaw`でラップしてください。インスタンスが不必要にリアクティブ化されることで、misskey.js内の処理で不具合が発生するとともに、パフォーマンス上の問題にも繋がる。なお、Composition APIを使う場合はこの限りではない(リアクティブ化はマニュアルなため)。

View File

@ -218,7 +218,17 @@ type NullOrUndefined<p extends Schema, T> =
// https://stackoverflow.com/questions/54938141/typescript-convert-union-to-intersection // https://stackoverflow.com/questions/54938141/typescript-convert-union-to-intersection
// Get intersection from union // Get intersection from union
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never; type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never;
type PartialIntersection<T> = Partial<UnionToIntersection<T>>;
type ArrayToIntersection<T extends ReadonlyArray<Schema>> =
T extends readonly [infer Head, ...infer Tail]
? Head extends Schema
? Tail extends ReadonlyArray<Schema>
? Tail extends []
? SchemaType<Head>
: SchemaType<Head> & ArrayToIntersection<Tail>
: never
: never
: never;
// https://github.com/misskey-dev/misskey/pull/8144#discussion_r785287552 // https://github.com/misskey-dev/misskey/pull/8144#discussion_r785287552
// To get union, we use `Foo extends any ? Hoge<Foo> : never` // To get union, we use `Foo extends any ? Hoge<Foo> : never`
@ -236,8 +246,8 @@ type ObjectSchemaTypeDef<p extends Schema> =
: never : never
: ObjType<p['properties'], NonNullable<p['required']>> : ObjType<p['properties'], NonNullable<p['required']>>
: :
p['anyOf'] extends ReadonlyArray<Schema> ? never : // see CONTRIBUTING.md p['anyOf'] extends ReadonlyArray<Schema> ? UnionSchemaType<p['anyOf']> :
p['allOf'] extends ReadonlyArray<Schema> ? UnionToIntersection<UnionSchemaType<p['allOf']>> : p['allOf'] extends ReadonlyArray<Schema> ? ArrayToIntersection<p['allOf']> :
p['additionalProperties'] extends true ? Record<string, any> : p['additionalProperties'] extends true ? Record<string, any> :
p['additionalProperties'] extends Schema ? p['additionalProperties'] extends Schema ?
p['additionalProperties'] extends infer AdditionalProperties ? p['additionalProperties'] extends infer AdditionalProperties ?
@ -277,7 +287,8 @@ export type SchemaTypeDef<p extends Schema> =
p['items'] extends NonNullable<Schema> ? SchemaType<p['items']>[] : p['items'] extends NonNullable<Schema> ? SchemaType<p['items']>[] :
any[] any[]
) : ) :
p['anyOf'] extends ReadonlyArray<Schema> ? UnionSchemaType<p['anyOf']> & PartialIntersection<UnionSchemaType<p['anyOf']>> : p['anyOf'] extends ReadonlyArray<Schema> ? UnionSchemaType<p['anyOf']> :
p['allOf'] extends ReadonlyArray<Schema> ? ArrayToIntersection<p['allOf']> :
p['oneOf'] extends ReadonlyArray<Schema> ? UnionSchemaType<p['oneOf']> : p['oneOf'] extends ReadonlyArray<Schema> ? UnionSchemaType<p['oneOf']> :
any; any;

View File

@ -162,14 +162,21 @@ export const meta = {
} as const; } as const;
export const paramDef = { export const paramDef = {
type: 'object',
properties: {
fileId: { type: 'string', format: 'misskey:id' },
url: { type: 'string' },
},
anyOf: [ anyOf: [
{ required: ['fileId'] }, {
{ required: ['url'] }, type: 'object',
properties: {
fileId: { type: 'string', format: 'misskey:id' },
},
required: ['fileId'],
},
{
type: 'object',
properties: {
url: { type: 'string' },
},
required: ['url'],
},
], ],
} as const; } as const;
@ -186,15 +193,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private idService: IdService, private idService: IdService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const file = ps.fileId ? await this.driveFilesRepository.findOneBy({ id: ps.fileId }) : await this.driveFilesRepository.findOne({ const file = await this.driveFilesRepository.findOneBy(
where: [{ 'fileId' in ps
url: ps.url, ? { id: ps.fileId }
}, { : [{ url: ps.url }, { thumbnailUrl: ps.url }, { webpublicUrl: ps.url }],
thumbnailUrl: ps.url, );
}, {
webpublicUrl: ps.url,
}],
});
if (file == null) { if (file == null) {
throw new ApiError(meta.errors.noSuchFile); throw new ApiError(meta.errors.noSuchFile);

View File

@ -37,29 +37,45 @@ export const meta = {
} as const; } as const;
export const paramDef = { export const paramDef = {
type: 'object', allOf: [
properties: { {
id: { type: 'string', format: 'misskey:id' }, anyOf: [
name: { type: 'string', pattern: '^[a-zA-Z0-9_]+$' }, {
fileId: { type: 'string', format: 'misskey:id' }, type: 'object',
category: { properties: {
type: 'string', id: { type: 'string', format: 'misskey:id' },
nullable: true, },
description: 'Use `null` to reset the category.', required: ['id'],
},
{
type: 'object',
properties: {
name: { type: 'string', pattern: '^[a-zA-Z0-9_]+$' },
},
required: ['name'],
},
],
},
{
type: 'object',
properties: {
fileId: { type: 'string', format: 'misskey:id' },
category: {
type: 'string',
nullable: true,
description: 'Use `null` to reset the category.',
},
aliases: { type: 'array', items: {
type: 'string',
} },
license: { type: 'string', nullable: true },
isSensitive: { type: 'boolean' },
localOnly: { type: 'boolean' },
roleIdsThatCanBeUsedThisEmojiAsReaction: { type: 'array', items: {
type: 'string',
} },
},
}, },
aliases: { type: 'array', items: {
type: 'string',
} },
license: { type: 'string', nullable: true },
isSensitive: { type: 'boolean' },
localOnly: { type: 'boolean' },
roleIdsThatCanBeUsedThisEmojiAsReaction: { type: 'array', items: {
type: 'string',
} },
},
anyOf: [
{ required: ['id'] },
{ required: ['name'] },
], ],
} as const; } as const;
@ -78,10 +94,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (driveFile == null) throw new ApiError(meta.errors.noSuchFile); if (driveFile == null) throw new ApiError(meta.errors.noSuchFile);
} }
// JSON schemeのanyOfの型変換がうまくいっていないらしい const required = 'id' in ps
const required = { id: ps.id, name: ps.name } as ? { id: ps.id, name: 'name' in ps ? ps.name as string : undefined }
| { id: MiEmoji['id']; name?: string } : { name: ps.name };
| { id?: MiEmoji['id']; name: string };
const error = await this.customEmojiService.update({ const error = await this.customEmojiService.update({
...required, ...required,

View File

@ -43,14 +43,21 @@ export const meta = {
} as const; } as const;
export const paramDef = { export const paramDef = {
type: 'object',
properties: {
fileId: { type: 'string', format: 'misskey:id' },
url: { type: 'string' },
},
anyOf: [ anyOf: [
{ required: ['fileId'] }, {
{ required: ['url'] }, type: 'object',
properties: {
fileId: { type: 'string', format: 'misskey:id' },
},
required: ['fileId'],
},
{
type: 'object',
properties: {
url: { type: 'string' },
},
required: ['url'],
},
], ],
} as const; } as const;
@ -64,21 +71,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private roleService: RoleService, private roleService: RoleService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
let file: MiDriveFile | null = null; const file = await this.driveFilesRepository.findOneBy(
'fileId' in ps
if (ps.fileId) { ? { id: ps.fileId }
file = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); : [{ url: ps.url }, { webpublicUrl: ps.url }, { thumbnailUrl: ps.url }],
} else if (ps.url) { );
file = await this.driveFilesRepository.findOne({
where: [{
url: ps.url,
}, {
webpublicUrl: ps.url,
}, {
thumbnailUrl: ps.url,
}],
});
}
if (file == null) { if (file == null) {
throw new ApiError(meta.errors.noSuchFile); throw new ApiError(meta.errors.noSuchFile);

View File

@ -15,14 +15,21 @@ export const meta = {
} as const; } as const;
export const paramDef = { export const paramDef = {
type: 'object',
properties: {
tokenId: { type: 'string', format: 'misskey:id' },
token: { type: 'string', nullable: true },
},
anyOf: [ anyOf: [
{ required: ['tokenId'] }, {
{ required: ['token'] }, type: 'object',
properties: {
tokenId: { type: 'string', format: 'misskey:id' },
},
required: ['tokenId'],
},
{
type: 'object',
properties: {
token: { type: 'string', nullable: true },
},
required: ['token'],
},
], ],
} as const; } as const;
@ -33,7 +40,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private accessTokensRepository: AccessTokensRepository, private accessTokensRepository: AccessTokensRepository,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
if (ps.tokenId) { if ('tokenId' in ps) {
const tokenExist = await this.accessTokensRepository.exists({ where: { id: ps.tokenId } }); const tokenExist = await this.accessTokensRepository.exists({ where: { id: ps.tokenId } });
if (tokenExist) { if (tokenExist) {

View File

@ -28,38 +28,53 @@ export const meta = {
} as const; } as const;
export const paramDef = { export const paramDef = {
type: 'object', allOf: [
properties: { {
reply: { type: 'boolean', nullable: true, default: null }, anyOf: [
renote: { type: 'boolean', nullable: true, default: null }, {
withFiles: { type: 'object',
type: 'boolean', properties: {
default: false, tag: { type: 'string', minLength: 1 },
description: 'Only show notes that have attached files.', },
}, required: ['tag'],
poll: { type: 'boolean', nullable: true, default: null },
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
tag: { type: 'string', minLength: 1 },
query: {
type: 'array',
description: 'The outer arrays are chained with OR, the inner arrays are chained with AND.',
items: {
type: 'array',
items: {
type: 'string',
minLength: 1,
}, },
minItems: 1, {
}, type: 'object',
minItems: 1, properties: {
query: {
type: 'array',
description: 'The outer arrays are chained with OR, the inner arrays are chained with AND.',
items: {
type: 'array',
items: {
type: 'string',
minLength: 1,
},
minItems: 1,
},
minItems: 1,
},
},
required: ['query'],
},
],
},
{
type: 'object',
properties: {
reply: { type: 'boolean', nullable: true, default: null },
renote: { type: 'boolean', nullable: true, default: null },
withFiles: {
type: 'boolean',
default: false,
description: 'Only show notes that have attached files.',
},
poll: { type: 'boolean', nullable: true, default: null },
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
},
}, },
},
anyOf: [
{ required: ['tag'] },
{ required: ['query'] },
], ],
} as const; } as const;
@ -87,12 +102,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (me) this.queryService.generateBlockedUserQueryForNotes(query, me); if (me) this.queryService.generateBlockedUserQueryForNotes(query, me);
try { try {
if (ps.tag) { if ('tag' in ps) {
if (!safeForSql(normalizeForSearch(ps.tag))) throw new Error('Injection'); if (!safeForSql(normalizeForSearch(ps.tag))) throw new Error('Injection');
query.andWhere(':tag <@ note.tags', { tag: [normalizeForSearch(ps.tag)] }); query.andWhere(':tag <@ note.tags', { tag: [normalizeForSearch(ps.tag)] });
} else { } else {
query.andWhere(new Brackets(qb => { query.andWhere(new Brackets(qb => {
for (const tags of ps.query!) { for (const tags of ps.query) {
qb.orWhere(new Brackets(qb => { qb.orWhere(new Brackets(qb => {
for (const tag of tags) { for (const tag of tags) {
if (!safeForSql(normalizeForSearch(tag))) throw new Error('Injection'); if (!safeForSql(normalizeForSearch(tag))) throw new Error('Injection');

View File

@ -33,15 +33,22 @@ export const meta = {
} as const; } as const;
export const paramDef = { export const paramDef = {
type: 'object',
properties: {
pageId: { type: 'string', format: 'misskey:id' },
name: { type: 'string' },
username: { type: 'string' },
},
anyOf: [ anyOf: [
{ required: ['pageId'] }, {
{ required: ['name', 'username'] }, type: 'object',
properties: {
pageId: { type: 'string', format: 'misskey:id' },
},
required: ['pageId'],
},
{
type: 'object',
properties: {
name: { type: 'string' },
username: { type: 'string' },
},
required: ['name', 'username'],
},
], ],
} as const; } as const;
@ -59,9 +66,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
let page: MiPage | null = null; let page: MiPage | null = null;
if (ps.pageId) { if ('pageId' in ps) {
page = await this.pagesRepository.findOneBy({ id: ps.pageId }); page = await this.pagesRepository.findOneBy({ id: ps.pageId });
} else if (ps.name && ps.username) { } else {
const author = await this.usersRepository.findOneBy({ const author = await this.usersRepository.findOneBy({
host: IsNull(), host: IsNull(),
usernameLower: ps.username.toLowerCase(), usernameLower: ps.username.toLowerCase(),

View File

@ -47,23 +47,38 @@ export const meta = {
} as const; } as const;
export const paramDef = { export const paramDef = {
type: 'object', allOf: [
properties: { {
sinceId: { type: 'string', format: 'misskey:id' }, anyOf: [
untilId: { type: 'string', format: 'misskey:id' }, {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, type: 'object',
properties: {
userId: { type: 'string', format: 'misskey:id' }, userId: { type: 'string', format: 'misskey:id' },
username: { type: 'string' }, },
host: { required: ['userId'],
type: 'string', },
nullable: true, {
description: 'The local host is represented with `null`.', type: 'object',
properties: {
username: { type: 'string' },
host: {
type: 'string',
nullable: true,
description: 'The local host is represented with `null`.',
},
},
required: ['username', 'host'],
},
],
},
{
type: 'object',
properties: {
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
},
}, },
},
anyOf: [
{ required: ['userId'] },
{ required: ['username', 'host'] },
], ],
} as const; } as const;
@ -85,9 +100,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private roleService: RoleService, private roleService: RoleService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const user = await this.usersRepository.findOneBy(ps.userId != null const user = await this.usersRepository.findOneBy('userId' in ps
? { id: ps.userId } ? { id: ps.userId }
: { usernameLower: ps.username!.toLowerCase(), host: this.utilityService.toPunyNullable(ps.host) ?? IsNull() }); : { usernameLower: ps.username.toLowerCase(), host: this.utilityService.toPunyNullable(ps.host) ?? IsNull() });
if (user == null) { if (user == null) {
throw new ApiError(meta.errors.noSuchUser); throw new ApiError(meta.errors.noSuchUser);

View File

@ -54,25 +54,39 @@ export const meta = {
} as const; } as const;
export const paramDef = { export const paramDef = {
type: 'object', allOf: [
properties: { {
sinceId: { type: 'string', format: 'misskey:id' }, anyOf: [
untilId: { type: 'string', format: 'misskey:id' }, {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, type: 'object',
properties: {
userId: { type: 'string', format: 'misskey:id' }, userId: { type: 'string', format: 'misskey:id' },
username: { type: 'string' }, },
host: { required: ['userId'],
type: 'string', },
nullable: true, {
description: 'The local host is represented with `null`.', type: 'object',
properties: {
username: { type: 'string' },
host: {
type: 'string',
nullable: true,
description: 'The local host is represented with `null`.',
},
},
required: ['username', 'host'],
},
],
},
{
type: 'object',
properties: {
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
birthday: { ...birthdaySchema, nullable: true },
},
}, },
birthday: { ...birthdaySchema, nullable: true },
},
anyOf: [
{ required: ['userId'] },
{ required: ['username', 'host'] },
], ],
} as const; } as const;
@ -94,9 +108,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private roleService: RoleService, private roleService: RoleService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const user = await this.usersRepository.findOneBy(ps.userId != null const user = await this.usersRepository.findOneBy('userId' in ps
? { id: ps.userId } ? { id: ps.userId }
: { usernameLower: ps.username!.toLowerCase(), host: this.utilityService.toPunyNullable(ps.host) ?? IsNull() }); : { usernameLower: ps.username.toLowerCase(), host: this.utilityService.toPunyNullable(ps.host) ?? IsNull() });
if (user == null) { if (user == null) {
throw new ApiError(meta.errors.noSuchUser); throw new ApiError(meta.errors.noSuchUser);

View File

@ -114,7 +114,7 @@ export const paramDef = {
type: 'object', type: 'object',
properties: { properties: {
userId: { userId: {
anyOf: [ oneOf: [
{ type: 'string', format: 'misskey:id' }, { type: 'string', format: 'misskey:id' },
{ {
type: 'array', type: 'array',

View File

@ -26,17 +26,32 @@ export const meta = {
} as const; } as const;
export const paramDef = { export const paramDef = {
type: 'object', allOf: [
properties: { {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, anyOf: [
detail: { type: 'boolean', default: true }, {
type: 'object',
username: { type: 'string', nullable: true }, properties: {
host: { type: 'string', nullable: true }, username: { type: 'string', nullable: true },
}, },
anyOf: [ required: ['username'],
{ required: ['username'] }, },
{ required: ['host'] }, {
type: 'object',
properties: {
host: { type: 'string', nullable: true },
},
required: ['host'],
},
],
},
{
type: 'object',
properties: {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
detail: { type: 'boolean', default: true },
},
},
], ],
} as const; } as const;
@ -47,8 +62,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
) { ) {
super(meta, paramDef, (ps, me) => { super(meta, paramDef, (ps, me) => {
return this.userSearchService.searchByUsernameAndHost({ return this.userSearchService.searchByUsernameAndHost({
username: ps.username, username: 'username' in ps ? ps.username : undefined,
host: ps.host, host: 'host' in ps ? ps.host : undefined,
}, { }, {
limit: ps.limit, limit: ps.limit,
detail: ps.detail, detail: ps.detail,

View File

@ -0,0 +1,23 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
process.env.NODE_ENV = 'test';
import { getValidator } from '../../../../../test/prelude/get-api-validator.js';
import { paramDef } from './show.js';
const VALID = true;
const INVALID = false;
describe('api:users/show', () => {
describe('validation', () => {
const v = getValidator(paramDef);
test('Reject empty', () => expect(v({})).toBe(INVALID));
test('Reject host only', () => expect(v({ host: 'misskey.test' })).toBe(INVALID));
test('Accept userId only', () => expect(v({ userId: '1' })).toBe(VALID));
test('Accept username and host', () => expect(v({ username: 'alice', host: 'misskey.test' })).toBe(VALID));
});
});

View File

@ -59,23 +59,44 @@ export const meta = {
} as const; } as const;
export const paramDef = { export const paramDef = {
type: 'object', allOf: [
properties: { {
userId: { type: 'string', format: 'misskey:id' }, anyOf: [
userIds: { type: 'array', uniqueItems: true, items: { {
type: 'string', format: 'misskey:id', type: 'object',
} }, properties: {
username: { type: 'string' }, userId: { type: 'string', format: 'misskey:id' },
host: { },
type: 'string', required: ['userId'],
nullable: true, },
description: 'The local host is represented with `null`.', {
type: 'object',
properties: {
userIds: { type: 'array', uniqueItems: true, items: {
type: 'string', format: 'misskey:id',
} },
},
required: ['userIds'],
},
{
type: 'object',
properties: {
username: { type: 'string' },
},
required: ['username'],
},
],
},
{
type: 'object',
properties: {
host: {
type: 'string',
nullable: true,
description: 'The local host is represented with `null`.',
},
},
}, },
},
anyOf: [
{ required: ['userId'] },
{ required: ['userIds'] },
{ required: ['username'] },
], ],
} as const; } as const;
@ -102,9 +123,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
let user; let user;
const isModerator = await this.roleService.isModerator(me); const isModerator = await this.roleService.isModerator(me);
ps.username = ps.username?.trim(); if ('username' in ps) {
ps.username = ps.username.trim();
}
if (ps.userIds) { if ('userIds' in ps) {
if (ps.userIds.length === 0) { if (ps.userIds.length === 0) {
return []; return [];
} }
@ -129,7 +152,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
return _users.map(u => _userMap.get(u.id)!); return _users.map(u => _userMap.get(u.id)!);
} else { } else {
// Lookup user // Lookup user
if (typeof ps.host === 'string' && typeof ps.username === 'string') { if (typeof ps.host === 'string' && 'username' in ps) {
if (this.serverSettings.ugcVisibilityForVisitor === 'local' && me == null) { if (this.serverSettings.ugcVisibilityForVisitor === 'local' && me == null) {
throw new ApiError(meta.errors.noSuchUser); throw new ApiError(meta.errors.noSuchUser);
} }
@ -139,7 +162,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.failedToResolveRemoteUser); throw new ApiError(meta.errors.failedToResolveRemoteUser);
}); });
} else { } else {
const q: FindOptionsWhere<MiUser> = ps.userId != null const q: FindOptionsWhere<MiUser> = 'userId' in ps
? { id: ps.userId } ? { id: ps.userId }
: { usernameLower: ps.username!.toLowerCase(), host: IsNull() }; : { usernameLower: ps.username!.toLowerCase(), host: IsNull() };

View File

@ -89,7 +89,8 @@ export function genOpenapiSpec(config: Config, includeSelfRef = false) {
schema.required = undefined; schema.required = undefined;
} }
const hasBody = (schema.type === 'object' && schema.properties && Object.keys(schema.properties).length >= 1); const hasBody = (schema.type === 'object' && schema.properties && Object.keys(schema.properties).length >= 1)
|| ['allOf', 'oneOf', 'anyOf'].some(o => (Array.isArray(schema[o]) && schema[o].length >= 0));
const info = { const info = {
operationId: endpoint.name.replaceAll('/', '___'), // NOTE: スラッシュは使えない operationId: endpoint.name.replaceAll('/', '___'), // NOTE: スラッシュは使えない

View File

@ -7306,8 +7306,9 @@ export type operations = {
content: { content: {
'application/json': { 'application/json': {
/** Format: misskey:id */ /** Format: misskey:id */
fileId?: string; fileId: string;
url?: string; } | {
url: string;
}; };
}; };
}; };
@ -8093,10 +8094,12 @@ export type operations = {
admin___emoji___update: { admin___emoji___update: {
requestBody: { requestBody: {
content: { content: {
'application/json': { 'application/json': ({
/** Format: misskey:id */ /** Format: misskey:id */
id?: string; id: string;
name?: string; } | {
name: string;
}) & ({
/** Format: misskey:id */ /** Format: misskey:id */
fileId?: string; fileId?: string;
/** @description Use `null` to reset the category. */ /** @description Use `null` to reset the category. */
@ -8106,7 +8109,7 @@ export type operations = {
isSensitive?: boolean; isSensitive?: boolean;
localOnly?: boolean; localOnly?: boolean;
roleIdsThatCanBeUsedThisEmojiAsReaction?: string[]; roleIdsThatCanBeUsedThisEmojiAsReaction?: string[];
}; });
}; };
}; };
responses: { responses: {
@ -16996,8 +16999,9 @@ export type operations = {
content: { content: {
'application/json': { 'application/json': {
/** Format: misskey:id */ /** Format: misskey:id */
fileId?: string; fileId: string;
url?: string; } | {
url: string;
}; };
}; };
}; };
@ -23096,9 +23100,10 @@ export type operations = {
content: { content: {
'application/json': { 'application/json': {
/** Format: misskey:id */ /** Format: misskey:id */
tokenId?: string; tokenId: string;
token?: string | null; } | ({
}; token: string | null;
});
}; };
}; };
responses: { responses: {
@ -25784,7 +25789,12 @@ export type operations = {
'notes___search-by-tag': { 'notes___search-by-tag': {
requestBody: { requestBody: {
content: { content: {
'application/json': { 'application/json': ({
tag: string;
} | {
/** @description The outer arrays are chained with OR, the inner arrays are chained with AND. */
query: string[][];
}) & ({
/** @default null */ /** @default null */
reply?: boolean | null; reply?: boolean | null;
/** @default null */ /** @default null */
@ -25802,10 +25812,7 @@ export type operations = {
untilId?: string; untilId?: string;
/** @default 10 */ /** @default 10 */
limit?: number; limit?: number;
tag?: string; });
/** @description The outer arrays are chained with OR, the inner arrays are chained with AND. */
query?: string[][];
};
}; };
}; };
responses: { responses: {
@ -26890,9 +26897,10 @@ export type operations = {
content: { content: {
'application/json': { 'application/json': {
/** Format: misskey:id */ /** Format: misskey:id */
pageId?: string; pageId: string;
name?: string; } | {
username?: string; name: string;
username: string;
}; };
}; };
}; };
@ -28979,18 +28987,20 @@ export type operations = {
users___followers: { users___followers: {
requestBody: { requestBody: {
content: { content: {
'application/json': { 'application/json': ({
/** Format: misskey:id */
userId: string;
} | ({
username: string;
/** @description The local host is represented with `null`. */
host: string | null;
})) & {
/** Format: misskey:id */ /** Format: misskey:id */
sinceId?: string; sinceId?: string;
/** Format: misskey:id */ /** Format: misskey:id */
untilId?: string; untilId?: string;
/** @default 10 */ /** @default 10 */
limit?: number; limit?: number;
/** Format: misskey:id */
userId?: string;
username?: string;
/** @description The local host is represented with `null`. */
host?: string | null;
}; };
}; };
}; };
@ -29042,20 +29052,22 @@ export type operations = {
users___following: { users___following: {
requestBody: { requestBody: {
content: { content: {
'application/json': { 'application/json': ({
/** Format: misskey:id */
userId: string;
} | ({
username: string;
/** @description The local host is represented with `null`. */
host: string | null;
})) & ({
/** Format: misskey:id */ /** Format: misskey:id */
sinceId?: string; sinceId?: string;
/** Format: misskey:id */ /** Format: misskey:id */
untilId?: string; untilId?: string;
/** @default 10 */ /** @default 10 */
limit?: number; limit?: number;
/** Format: misskey:id */
userId?: string;
username?: string;
/** @description The local host is represented with `null`. */
host?: string | null;
birthday?: string | null; birthday?: string | null;
}; });
}; };
}; };
responses: { responses: {
@ -30337,13 +30349,15 @@ export type operations = {
'users___search-by-username-and-host': { 'users___search-by-username-and-host': {
requestBody: { requestBody: {
content: { content: {
'application/json': { 'application/json': (({
username: string | null;
}) | ({
host: string | null;
})) & {
/** @default 10 */ /** @default 10 */
limit?: number; limit?: number;
/** @default true */ /** @default true */
detail?: boolean; detail?: boolean;
username?: string | null;
host?: string | null;
}; };
}; };
}; };
@ -30395,14 +30409,17 @@ export type operations = {
users___show: { users___show: {
requestBody: { requestBody: {
content: { content: {
'application/json': { 'application/json': ({
/** Format: misskey:id */ /** Format: misskey:id */
userId?: string; userId: string;
userIds?: string[]; } | {
username?: string; userIds: string[];
} | {
username: string;
}) & ({
/** @description The local host is represented with `null`. */ /** @description The local host is represented with `null`. */
host?: string | null; host?: string | null;
}; });
}; };
}; };
responses: { responses: {