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

@@ -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: []}]);
});
});