mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +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
@@ -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}`);
|
||||
|
||||
Reference in new issue
Block a user