feat(threads): thread and forum API, admin, schemas and docs (#3251)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-07 15:25:12 +02:00
1 parent 5f4295e399
commit 3f4160b138
495 files changed
+39232 -1215

No files matched your search

+4
View File
@@ -19,8 +19,10 @@ export interface ExtractedRoute {
middlewares: Array<string>;
hasLoginRequired: boolean;
hasDefaultUserOnly: boolean;
hasBotOnly: boolean;
rateLimitConfig: string | null;
responseSchemaName: string | null;
acceptedResponseSchemaName?: string | null;
responseContentType: string;
hasNoContent: boolean;
bodylessStatusCodes: Array<number>;
@@ -39,6 +41,7 @@ export interface ExtractedRoute {
explicitTags: Array<string> | null;
explicitDeprecated: boolean;
explicitExternalDocs: OpenAPIExternalDocs | null;
explicitExperiment: string | null;
}
export interface OpenAPIPathItem {
[method: string]: OpenAPIOperation;
@@ -54,6 +57,7 @@ export interface OpenAPIOperation {
responses: Record<string, OpenAPIResponse>;
deprecated?: boolean;
externalDocs?: OpenAPIExternalDocs;
'x-fluxer-experiment'?: string;
}
export interface OpenAPIParameter {
name: string;
@@ -16,16 +16,54 @@ describe('OpenAPI generation from API controllers', () => {
required: false,
content: {'application/json': {schema: {$ref: '#/components/schemas/ChannelUpdateRequestBody'}}},
});
const options = document.components.schemas.ChannelUpdateRequestBody.anyOf;
expect(options).toHaveLength(6);
const withType = (options ?? []).filter((option) => option.properties && 'type' in option.properties);
const options = document.components.schemas.ChannelUpdateRequestBody.anyOf ?? [];
expect(options).toHaveLength(11);
const withType = options.filter((option) => option.properties && 'type' in option.properties);
expect(withType).toHaveLength(2);
for (const option of withType) {
expect(option.properties?.type).toMatchObject({anyOf: [{const: 0}, {const: 5}]});
}
for (const option of options ?? []) {
for (const option of options.slice(0, 6)) {
expect(option.required).toBeUndefined();
}
expect(options.slice(6)).toEqual([
{$ref: '#/components/schemas/ChannelUpdatePublicThreadRequestBody'},
{$ref: '#/components/schemas/ChannelUpdatePrivateThreadRequestBody'},
{$ref: '#/components/schemas/ChannelUpdateThreadParentRequestBody'},
{$ref: '#/components/schemas/ChannelUpdateForumRequestBody'},
{$ref: '#/components/schemas/ChannelUpdateMediaRequestBody'},
]);
for (const name of [
'ChannelUpdateThreadParentRequestBody',
'ChannelUpdateForumRequestBody',
'ChannelUpdateMediaRequestBody',
]) {
const schema = document.components.schemas[name];
expect(schema['x-fluxer-experiment']).toBe('channel_threads');
expect(schema.properties).not.toHaveProperty('type');
expect(schema.properties).toHaveProperty('default_auto_archive_duration');
}
expect(document.components.schemas.ChannelUpdateForumRequestBody.properties).toHaveProperty('available_tags');
expect(document.components.schemas.ChannelCreateRequest.oneOf).toEqual(
expect.arrayContaining([
{$ref: '#/components/schemas/GuildForumChannelCreateRequest'},
{$ref: '#/components/schemas/GuildMediaChannelCreateRequest'},
]),
);
for (const name of ['ChannelUpdatePublicThreadRequestBody', 'ChannelUpdatePrivateThreadRequestBody']) {
const schema = document.components.schemas[name];
expect(schema['x-fluxer-experiment']).toBe('channel_threads');
expect(schema.properties).not.toHaveProperty('type');
expect(schema.properties).toHaveProperty('archived');
expect(schema.required).toBeUndefined();
}
expect(document.components.schemas.ChannelUpdatePrivateThreadRequestBody.properties).toHaveProperty('invitable');
});
it('documents the bodyless response of thread member settings', () => {
expect(document.paths['/channels/{channel_id}/thread-members/@me/settings'].patch.responses['204']).toEqual({
description: 'No Content',
});
});
it('keeps path-supplied voice IDs out of JSON request bodies', () => {
@@ -45,6 +83,39 @@ describe('OpenAPI generation from API controllers', () => {
]);
});
it('tags every channel thread operation with its experiment', () => {
const tagged = Object.values(document.paths)
.flatMap((pathItem) => Object.values(pathItem))
.filter((operation) => operation['x-fluxer-experiment'] === 'channel_threads')
.map((operation) => operation.operationId)
.sort();
expect(tagged).toEqual(
[
'start_thread_from_message',
'start_thread',
'list_guild_active_threads',
'list_public_archived_threads',
'list_private_archived_threads',
'list_joined_private_archived_threads',
'list_thread_members',
'get_thread_member',
'join_thread',
'add_thread_member',
'leave_thread',
'remove_thread_member',
'update_thread_member_settings',
'search_threads',
'get_channel_post_data',
'create_forum_tag',
'update_forum_tag',
'delete_forum_tag',
'list_admin_guild_threads',
'delete_admin_thread_channel',
].sort(),
);
expect(document.paths['/channels/{channel_id}'].get).not.toHaveProperty('x-fluxer-experiment');
});
it('retains the explicit optional sudo body on channel deletion', () => {
expect(document.paths['/channels/{channel_id}'].delete.requestBody).toMatchObject({
required: false,
@@ -36,6 +36,7 @@ function applyMetadata(schema: core.$ZodType, json: OpenAPISchema): void {
if (!metadata) return;
if (metadata.format) json.format = metadata.format;
if (metadata.bitflagValues) json['x-bitflagValues'] = metadata.bitflagValues;
if (metadata.experiment) json['x-fluxer-experiment'] = metadata.experiment;
const entries = metadata.enumEntries;
if (!entries) return;
json['x-enumNames'] = entries.map((entry) => entry.name);
@@ -25,7 +25,12 @@ import {
import {z} from 'zod';
const HTTP_METHODS: ReadonlySet<string> = new Set(['get', 'post', 'put', 'patch', 'delete']);
const SCHEMA_METADATA_FIELDS: ReadonlySet<string> = new Set(['responseSchema', 'requestSchema', 'requestFormSchema']);
const SCHEMA_METADATA_FIELDS: ReadonlySet<string> = new Set([
'responseSchema',
'acceptedResponseSchema',
'requestSchema',
'requestFormSchema',
]);
const MetadataStringList = z
.union([z.string(), z.array(z.string())])
.transform((value) => (typeof value === 'string' ? [value] : value));
@@ -36,6 +41,7 @@ const RouteMetadata = z.strictObject({
summary: z.string().min(1),
description: z.string(),
responseSchema: z.string().min(1).nullable(),
acceptedResponseSchema: z.string().min(1).optional(),
responseContentType: z.string().optional(),
requestSchema: z.string().min(1).optional(),
requestFormSchema: z.string().min(1).optional(),
@@ -52,6 +58,10 @@ const RouteMetadata = z.strictObject({
tags: MetadataStringList,
deprecated: z.boolean().optional(),
externalDocs: z.strictObject({url: z.string(), description: z.string().optional()}).optional(),
experiment: z
.string()
.regex(/^[a-z][a-z0-9_]*$/)
.optional(),
});
const RouteOptionsMetadata = RouteMetadata.pick({description: true, responseContentType: true}).extend({
description: z.string().min(1),
@@ -130,6 +140,13 @@ function parseObjectLiteralMetadata(
}
function extractValidatorInfo(callExpr: CallExpression): ExtractedValidator | null {
const expression = callExpr.getExpression();
if (Node.isIdentifier(expression) && expression.getText() === 'GatedJsonValidator') {
const [controlArg] = callExpr.getArguments();
if (!controlArg || !Node.isIdentifier(controlArg)) {
throw new Error(`GatedJsonValidator must use a named control schema export: ${callExpr.getText()}`);
}
return {target: 'json', schemaName: controlArg.getText()};
}
if (!Node.isIdentifier(expression) || expression.getText() !== 'Validator') return null;
const [targetArg, schemaArg] = callExpr.getArguments();
if (!targetArg || !schemaArg) throw new Error(`Validator requires a target and named schema: ${callExpr.getText()}`);
@@ -144,6 +161,7 @@ interface MiddlewareInfo {
middlewareName: string;
rateLimitConfig?: string;
responseSchemaName?: string;
acceptedResponseSchemaName?: string;
responseContentType?: string;
hasNoContent?: boolean;
bodylessStatusCodes?: Array<number> | null;
@@ -161,6 +179,7 @@ interface MiddlewareInfo {
explicitTags?: Array<string> | null;
explicitDeprecated?: boolean;
explicitExternalDocs?: OpenAPIExternalDocs;
explicitExperiment?: string;
}
function extractOpenAPIMetadata(args: ReadonlyArray<Node>, context: MetadataContext): MiddlewareInfo {
const [first, summary, responseSchema, options] = args;
@@ -185,6 +204,7 @@ function extractOpenAPIMetadata(args: ReadonlyArray<Node>, context: MetadataCont
return {
middlewareName: 'OpenAPI',
responseSchemaName: metadata.responseSchema ?? undefined,
acceptedResponseSchemaName: metadata.acceptedResponseSchema,
hasNoContent: metadata.responseSchema === null,
bodylessStatusCodes: metadata.bodylessStatusCodes,
responseContentType: metadata.responseContentType,
@@ -199,6 +219,7 @@ function extractOpenAPIMetadata(args: ReadonlyArray<Node>, context: MetadataCont
explicitTags: metadata.tags,
explicitDeprecated: metadata.deprecated,
explicitExternalDocs: metadata.externalDocs,
explicitExperiment: metadata.experiment,
};
}
function extractMiddlewareInfo(callExpr: CallExpression, context: MetadataContext): MiddlewareInfo | null {
@@ -375,6 +396,7 @@ function buildRoute(
middlewares: [],
hasLoginRequired: false,
hasDefaultUserOnly: false,
hasBotOnly: false,
rateLimitConfig: null,
responseSchemaName: null,
responseContentType: 'application/json',
@@ -395,6 +417,7 @@ function buildRoute(
explicitTags: null,
explicitDeprecated: false,
explicitExternalDocs: null,
explicitExperiment: null,
};
const context = {resolver, scope};
for (const arg of registration.middlewareArguments) {
@@ -403,6 +426,7 @@ function buildRoute(
route.middlewares.push(name);
if (name === 'LoginRequired') route.hasLoginRequired = true;
if (name === 'DefaultUserOnly') route.hasDefaultUserOnly = true;
if (name === 'BotOnly') route.hasBotOnly = true;
continue;
}
if (Node.isArrowFunction(arg) || Node.isFunctionExpression(arg)) {
@@ -420,6 +444,9 @@ function buildRoute(
route.middlewares.push(middleware.middlewareName);
if (middleware.rateLimitConfig) route.rateLimitConfig = middleware.rateLimitConfig;
if (middleware.responseSchemaName) route.responseSchemaName = middleware.responseSchemaName;
if (middleware.acceptedResponseSchemaName) {
route.acceptedResponseSchemaName = middleware.acceptedResponseSchemaName;
}
if (middleware.responseContentType) route.responseContentType = middleware.responseContentType;
if (middleware.hasNoContent) route.hasNoContent = true;
if (middleware.bodylessStatusCodes) route.bodylessStatusCodes = middleware.bodylessStatusCodes;
@@ -450,6 +477,7 @@ function buildRoute(
if (middleware.explicitTags) route.explicitTags = middleware.explicitTags;
if (middleware.explicitDeprecated) route.explicitDeprecated = true;
if (middleware.explicitExternalDocs) route.explicitExternalDocs = middleware.explicitExternalDocs;
if (middleware.explicitExperiment) route.explicitExperiment = middleware.explicitExperiment;
}
return route;
}
@@ -21,6 +21,9 @@ function successStatusCodes(route: ExtractedRoute): Array<number> {
if (route.successStatusCodes.length) return route.successStatusCodes;
return [route.hasNoContent ? 204 : 200];
}
function isBodyless(route: ExtractedRoute, code: number): boolean {
return code === 204 || route.bodylessStatusCodes.includes(code) || (route.hasNoContent && !route.responseSchemaName);
}
export class OpenAPIOperationBuilder {
private readonly schemaRegistry: SchemaRegistry;
private readonly usedOperationIds: Set<string>;
@@ -64,6 +67,9 @@ export class OpenAPIOperationBuilder {
if (route.explicitExternalDocs) {
operation.externalDocs = route.explicitExternalDocs;
}
if (route.explicitExperiment) {
operation['x-fluxer-experiment'] = route.explicitExperiment;
}
if (security.length > 0) {
operation.security = security;
}
@@ -90,6 +96,9 @@ export class OpenAPIOperationBuilder {
if (route.path === '/users/@me' || route.path.startsWith('/users/@me/')) {
return [{bearerToken: []}, {sessionToken: []}];
}
if (route.hasBotOnly) {
return [{botToken: []}];
}
if (!route.hasLoginRequired && !route.hasDefaultUserOnly) {
return [];
}
@@ -202,21 +211,31 @@ export class OpenAPIOperationBuilder {
responses['304'] = {description: 'Not Modified'};
continue;
}
if (
code === 204 ||
route.bodylessStatusCodes.includes(code) ||
(route.hasNoContent && !route.responseSchemaName)
) {
if (code === 202 && route.acceptedResponseSchemaName) {
responses['202'] = {
description: 'Accepted',
content: {
'application/json': {schema: this.schemaRegistry.getRef(route.acceptedResponseSchemaName, 'output')},
},
};
continue;
}
if (isBodyless(route, code)) {
responses[String(code)] = {description: code === 204 ? 'No Content' : 'Success'};
continue;
}
if (!route.responseSchemaName) {
throw new Error(`Missing response schema for ${route.method.toUpperCase()} ${route.path}`);
}
const ref = this.schemaRegistry.getRef(route.responseSchemaName, 'output');
responses[String(code)] = {
description: 'Success',
content: {
[route.responseContentType]: {schema: this.schemaRegistry.getRef(route.responseSchemaName, 'output')},
[route.responseContentType]: {
schema: statusCodes.some((other) => other >= 200 && other < 300 && isBodyless(route, other))
? this.schemaRegistry.nullable(ref)
: ref,
},
},
};
}
@@ -0,0 +1,82 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {extractRoutesFromControllers} from '@fluxer/openapi/src/extractors/RouteExtractor';
import {OpenAPIOperationBuilder} from '@fluxer/openapi/src/generator/OpenAPIOperationBuilder';
import type {OpenAPISchemaTarget} from '@fluxer/openapi/src/OpenAPIGenerationTypes';
import type {ExtractedRoute} from '@fluxer/openapi/src/OpenAPITypes';
import {SchemaRegistry} from '@fluxer/openapi/src/registry/SchemaRegistry';
import {afterAll, beforeAll, describe, expect, it} from 'vitest';
const CONTROLLER_SOURCE = `
export function FixtureController(app: HonoApp) {
app.patch(
'/fixture/maybe',
LoginRequired,
OpenAPI({
operationId: 'fixture_maybe',
summary: 'Fixture',
description: 'Fixture route.',
responseSchema: FixtureResponse,
statusCode: [200, 204],
bodylessStatusCodes: [204],
tags: ['Fixture'],
}),
async (ctx) => ctx.body(null, 204),
);
app.get(
'/fixture/always',
LoginRequired,
OpenAPI({
operationId: 'fixture_always',
summary: 'Fixture',
description: 'Fixture route.',
responseSchema: FixtureResponse,
tags: ['Fixture'],
}),
async (ctx) => ctx.json({}),
);
}
`;
const REF = {$ref: '#/components/schemas/FixtureResponse'};
describe('operation responses', () => {
let directory: string;
let routes: Map<string, ExtractedRoute>;
beforeAll(() => {
directory = fs.mkdtempSync(path.join(os.tmpdir(), 'openapi-responses-'));
const controllerPath = path.join(directory, 'FixtureController.ts');
fs.writeFileSync(controllerPath, CONTROLLER_SOURCE);
routes = new Map(extractRoutesFromControllers([controllerPath]).map((route) => [route.path, route]));
});
afterAll(() => {
fs.rmSync(directory, {recursive: true, force: true});
});
function successSchema(routePath: string, target?: OpenAPISchemaTarget) {
const route = routes.get(routePath);
if (!route) throw new Error(`Missing fixture route ${routePath}`);
const schemaRegistry = new SchemaRegistry(target);
schemaRegistry.register('FixtureResponse', {type: 'object'});
const builder = new OpenAPIOperationBuilder({schemaRegistry, usedOperationIds: new Set()});
const responses = builder.buildOperation(route).responses;
return {schema: responses['200'].content?.['application/json'].schema, responses};
}
it('marks the body nullable when another success status has no body', () => {
const {schema, responses} = successSchema('/fixture/maybe');
expect(schema).toEqual({anyOf: [REF, {type: 'null'}]});
expect(responses['204']).toEqual({description: 'No Content'});
});
it('uses nullable for the openapi 3.0 target', () => {
expect(successSchema('/fixture/maybe', 'openapi-3.0').schema).toEqual({allOf: [REF], nullable: true});
});
it('keeps a plain reference when every success status has a body', () => {
expect(successSchema('/fixture/always').schema).toEqual(REF);
});
});
@@ -0,0 +1,96 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {extractRoutesFromControllers} from '@fluxer/openapi/src/extractors/RouteExtractor';
import {OpenAPIOperationBuilder} from '@fluxer/openapi/src/generator/OpenAPIOperationBuilder';
import type {ExtractedRoute} from '@fluxer/openapi/src/OpenAPITypes';
import {SchemaRegistry} from '@fluxer/openapi/src/registry/SchemaRegistry';
import {afterAll, beforeAll, describe, expect, it} from 'vitest';
const CONTROLLER_SOURCE = `
export function FixtureController(app: HonoApp) {
app.get(
'/fixture/bot',
LoginRequired,
BotOnly,
OpenAPI({
operationId: 'fixture_bot',
summary: 'Fixture',
description: 'Fixture route.',
responseSchema: null,
statusCode: 204,
tags: ['Fixture'],
}),
async (ctx) => ctx.body(null, 204),
);
app.get(
'/fixture/user',
LoginRequired,
DefaultUserOnly,
OpenAPI({
operationId: 'fixture_user',
summary: 'Fixture',
description: 'Fixture route.',
responseSchema: null,
statusCode: 204,
tags: ['Fixture'],
}),
async (ctx) => ctx.body(null, 204),
);
app.get(
'/fixture/any',
LoginRequired,
OpenAPI({
operationId: 'fixture_any',
summary: 'Fixture',
description: 'Fixture route.',
responseSchema: null,
statusCode: 204,
tags: ['Fixture'],
}),
async (ctx) => ctx.body(null, 204),
);
}
`;
describe('operation security', () => {
let directory: string;
let routes: Map<string, ExtractedRoute>;
beforeAll(() => {
directory = fs.mkdtempSync(path.join(os.tmpdir(), 'openapi-security-'));
const controllerPath = path.join(directory, 'FixtureController.ts');
fs.writeFileSync(controllerPath, CONTROLLER_SOURCE);
routes = new Map(extractRoutesFromControllers([controllerPath]).map((route) => [route.path, route]));
});
afterAll(() => {
fs.rmSync(directory, {recursive: true, force: true});
});
function securityOf(routePath: string) {
const route = routes.get(routePath);
if (!route) throw new Error(`Missing fixture route ${routePath}`);
const builder = new OpenAPIOperationBuilder({schemaRegistry: new SchemaRegistry(), usedOperationIds: new Set()});
return {route, security: builder.buildOperation(route).security};
}
it('declares bot token security for a BotOnly route', () => {
const {route, security} = securityOf('/fixture/bot');
expect(route.hasBotOnly).toBe(true);
expect(security).toEqual([{botToken: []}]);
});
it('keeps DefaultUserOnly routes on user tokens', () => {
const {route, security} = securityOf('/fixture/user');
expect(route.hasBotOnly).toBe(false);
expect(route.hasDefaultUserOnly).toBe(true);
expect(security).toEqual([{sessionToken: []}]);
});
it('keeps LoginRequired routes open to bot and user tokens', () => {
const {route, security} = securityOf('/fixture/any');
expect(route.hasBotOnly).toBe(false);
expect(security).toEqual([{botToken: []}, {sessionToken: []}]);
});
});
@@ -12,10 +12,17 @@ export class SchemaRegistry {
private readonly emptyObjectAcceptance = new WeakMap<core.$ZodType, boolean>();
private readonly converter: ZodOpenAPIConverter;
constructor(target: OpenAPISchemaTarget = 'draft-2020-12', nameUnionBranches = false) {
constructor(
private readonly target: OpenAPISchemaTarget = 'draft-2020-12',
nameUnionBranches = false,
) {
this.converter = new ZodOpenAPIConverter(target, nameUnionBranches);
}
nullable(schema: OpenAPISchema): OpenAPISchema {
return this.target === 'openapi-3.0' ? {allOf: [schema], nullable: true} : {anyOf: [schema, {type: 'null'}]};
}
register(name: string, schema: OpenAPISchema): void {
validateOpenAPIComponentName(name);
if (this.zodSchemas.has(name)) throw new Error(`OpenAPI schema also has a Zod definition: ${name}`);