mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
feat(threads): thread and forum API, admin, schemas and docs (#3251)
This commit is contained in:
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: []}]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user