refactor progress
This commit is contained in:
201
packages/openapi/src/generator/OpenAPIGeneratorCatalog.tsx
Normal file
201
packages/openapi/src/generator/OpenAPIGeneratorCatalog.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {
|
||||
APIErrorCodeSchema,
|
||||
Base64ImageTypeSchema,
|
||||
EmailTypeSchema,
|
||||
Int32TypeSchema,
|
||||
Int64StringTypeSchema,
|
||||
Int64TypeSchema,
|
||||
LocaleSchema,
|
||||
PasswordTypeSchema,
|
||||
PhoneNumberTypeSchema,
|
||||
SnowflakeTypeSchema,
|
||||
UnsignedInt64TypeSchema,
|
||||
UsernameTypeSchema,
|
||||
} from '@fluxer/openapi/src/converters/BuiltInSchemas';
|
||||
import type {OpenAPISchema, OpenAPISecurityScheme} from '@fluxer/openapi/src/OpenAPITypes';
|
||||
import {ERROR_SCHEMA} from '@fluxer/openapi/src/registry/ResponseRegistry';
|
||||
|
||||
const ORDERED_TAG_NAMES = [
|
||||
'Auth',
|
||||
'Users',
|
||||
'Guilds',
|
||||
'Channels',
|
||||
'Invites',
|
||||
'Packs',
|
||||
'Webhooks',
|
||||
'OAuth2',
|
||||
'Gateway',
|
||||
'Search',
|
||||
'Read States',
|
||||
'KLIPY',
|
||||
'Saved Media',
|
||||
'Themes',
|
||||
'Downloads',
|
||||
'Reports',
|
||||
'Instance',
|
||||
'Admin',
|
||||
'Billing',
|
||||
'Premium',
|
||||
'Gifts',
|
||||
'RPC',
|
||||
] as const;
|
||||
|
||||
interface TagDefinition {
|
||||
readonly name: (typeof ORDERED_TAG_NAMES)[number];
|
||||
readonly description: string;
|
||||
}
|
||||
|
||||
const TAG_DEFINITIONS: ReadonlyArray<TagDefinition> = [
|
||||
{name: 'Auth', description: 'Authentication and session management'},
|
||||
{name: 'Users', description: 'User accounts and profiles'},
|
||||
{name: 'Guilds', description: 'Guild management'},
|
||||
{name: 'Channels', description: 'Channel management and messaging'},
|
||||
{name: 'Invites', description: 'Guild invitations'},
|
||||
{name: 'Packs', description: 'Sticker and emoji packs'},
|
||||
{name: 'Webhooks', description: 'Webhook management'},
|
||||
{name: 'OAuth2', description: 'OAuth2 applications and authorization'},
|
||||
{name: 'Gateway', description: 'WebSocket gateway information'},
|
||||
{name: 'Search', description: 'Search functionality'},
|
||||
{name: 'Read States', description: 'Message read state tracking'},
|
||||
{name: 'KLIPY', description: 'GIF search via KLIPY'},
|
||||
{name: 'Saved Media', description: 'User saved media management'},
|
||||
{name: 'Themes', description: 'User interface themes'},
|
||||
{name: 'Downloads', description: 'App downloads'},
|
||||
{name: 'Reports', description: 'Content reporting'},
|
||||
{name: 'Instance', description: 'Instance configuration and info'},
|
||||
{name: 'Admin', description: 'Administrative operations for instance management'},
|
||||
{name: 'Billing', description: 'Subscription and payment management via Stripe'},
|
||||
{name: 'Premium', description: 'Premium subscription features and benefits'},
|
||||
{name: 'Gifts', description: 'Gift codes and redemption'},
|
||||
{name: 'RPC', description: 'Remote procedure call endpoints for internal operations'},
|
||||
] as const;
|
||||
|
||||
const TAG_DESCRIPTIONS = TAG_DEFINITIONS.reduce<Record<(typeof ORDERED_TAG_NAMES)[number], string>>(
|
||||
(acc, definition) => {
|
||||
acc[definition.name] = definition.description;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<(typeof ORDERED_TAG_NAMES)[number], string>,
|
||||
);
|
||||
|
||||
const SECURITY_SCHEMES: Record<string, OpenAPISecurityScheme> = {
|
||||
botToken: {
|
||||
type: 'apiKey',
|
||||
in: 'header',
|
||||
name: 'Authorization',
|
||||
description:
|
||||
'Bot token: `Authorization: Bot <token>`. This is the primary authentication method for bot applications.',
|
||||
},
|
||||
oauth2Token: {
|
||||
type: 'oauth2',
|
||||
description: 'OAuth2 access token: `Authorization: Bearer <token>`.',
|
||||
flows: {
|
||||
authorizationCode: {
|
||||
authorizationUrl: '/oauth2/authorize',
|
||||
tokenUrl: '/oauth2/token',
|
||||
scopes: {
|
||||
identify: 'Read basic user identity information.',
|
||||
email: 'Read the user email address.',
|
||||
guilds: 'Read guild membership information for the current user.',
|
||||
connections: 'Read linked third-party account connections for the current user.',
|
||||
bot: 'Add a bot user to a guild.',
|
||||
admin: 'Access admin endpoints when the user has admin ACLs.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
bearerToken: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
description:
|
||||
'Bearer-form token: `Authorization: Bearer <token>`. Use `oauth2Token` when a route requires OAuth2 scopes.',
|
||||
},
|
||||
sessionToken: {
|
||||
type: 'apiKey',
|
||||
in: 'header',
|
||||
name: 'Authorization',
|
||||
description:
|
||||
'User session token from login: `Authorization: <token>` (no prefix). Prefer a bot account over user tokens where possible.',
|
||||
},
|
||||
adminApiKey: {
|
||||
type: 'apiKey',
|
||||
in: 'header',
|
||||
name: 'Authorization',
|
||||
description: 'Admin API key: `Authorization: Admin <token>`. Only valid for `/admin/*` endpoints.',
|
||||
},
|
||||
};
|
||||
|
||||
const BUILT_IN_SCHEMAS: ReadonlyArray<readonly [string, OpenAPISchema]> = [
|
||||
['Error', ERROR_SCHEMA],
|
||||
['APIErrorCode', APIErrorCodeSchema],
|
||||
['SnowflakeType', SnowflakeTypeSchema],
|
||||
['Int32Type', Int32TypeSchema],
|
||||
['Int64Type', Int64TypeSchema],
|
||||
['Int64StringType', Int64StringTypeSchema],
|
||||
['UnsignedInt64Type', UnsignedInt64TypeSchema],
|
||||
['UsernameType', UsernameTypeSchema],
|
||||
['EmailType', EmailTypeSchema],
|
||||
['PasswordType', PasswordTypeSchema],
|
||||
['PhoneNumberType', PhoneNumberTypeSchema],
|
||||
['Base64ImageType', Base64ImageTypeSchema],
|
||||
['Locale', LocaleSchema],
|
||||
];
|
||||
|
||||
export interface IOpenAPIGeneratorCatalog {
|
||||
readonly excluded: {
|
||||
readonly prefixes: ReadonlyArray<string>;
|
||||
readonly paths: ReadonlySet<string>;
|
||||
};
|
||||
readonly tags: {
|
||||
readonly order: ReadonlyArray<string>;
|
||||
readonly descriptions: Record<string, string>;
|
||||
};
|
||||
readonly securitySchemes: Record<string, OpenAPISecurityScheme>;
|
||||
readonly builtInSchemas: ReadonlyArray<readonly [string, OpenAPISchema]>;
|
||||
}
|
||||
|
||||
export const OpenAPIGeneratorCatalog: IOpenAPIGeneratorCatalog = {
|
||||
excluded: {
|
||||
prefixes: ['/test/'],
|
||||
paths: new Set<string>(['/_rpc', '/oauth2/authorize']),
|
||||
},
|
||||
tags: {
|
||||
order: ORDERED_TAG_NAMES,
|
||||
descriptions: TAG_DESCRIPTIONS,
|
||||
},
|
||||
securitySchemes: SECURITY_SCHEMES,
|
||||
builtInSchemas: BUILT_IN_SCHEMAS,
|
||||
};
|
||||
|
||||
export function isExcludedRoutePath(routePath: string): boolean {
|
||||
if (OpenAPIGeneratorCatalog.excluded.paths.has(routePath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const prefix of OpenAPIGeneratorCatalog.excluded.prefixes) {
|
||||
if (routePath.startsWith(prefix) || routePath === prefix.slice(0, -1)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
723
packages/openapi/src/generator/OpenAPIOperationBuilder.tsx
Normal file
723
packages/openapi/src/generator/OpenAPIOperationBuilder.tsx
Normal file
@@ -0,0 +1,723 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {SnowflakeTypeRef} from '@fluxer/openapi/src/converters/BuiltInSchemas';
|
||||
import {analyzeSecurityRequirements} from '@fluxer/openapi/src/extractors/MiddlewareAnalyzer';
|
||||
import type {
|
||||
ExtractedRoute,
|
||||
ExtractedValidator,
|
||||
OpenAPIOperation,
|
||||
OpenAPIParameter,
|
||||
OpenAPIRequestBody,
|
||||
OpenAPIResponse,
|
||||
OpenAPISchema,
|
||||
OpenAPISchemaOrRef,
|
||||
} from '@fluxer/openapi/src/OpenAPITypes';
|
||||
import {extractPathParameters} from '@fluxer/openapi/src/registry/ParameterRegistry';
|
||||
import {getErrorResponses, getNoContentResponse} from '@fluxer/openapi/src/registry/ResponseRegistry';
|
||||
import type {LoadedSchema} from '@fluxer/openapi/src/registry/SchemaLoader';
|
||||
import type {SchemaRegistry} from '@fluxer/openapi/src/registry/SchemaRegistry';
|
||||
|
||||
interface OpenAPIOperationBuilderDependencies {
|
||||
readonly schemaRegistry: SchemaRegistry;
|
||||
readonly loadedSchemas: Map<string, LoadedSchema>;
|
||||
readonly usedOperationIds: Set<string>;
|
||||
}
|
||||
|
||||
export class OpenAPIOperationBuilder {
|
||||
private readonly schemaRegistry: SchemaRegistry;
|
||||
private readonly loadedSchemas: Map<string, LoadedSchema>;
|
||||
private readonly usedOperationIds: Set<string>;
|
||||
|
||||
constructor(dependencies: OpenAPIOperationBuilderDependencies) {
|
||||
this.schemaRegistry = dependencies.schemaRegistry;
|
||||
this.loadedSchemas = dependencies.loadedSchemas;
|
||||
this.usedOperationIds = dependencies.usedOperationIds;
|
||||
}
|
||||
|
||||
public buildOperation(route: ExtractedRoute): OpenAPIOperation {
|
||||
if (!route.explicitTags || route.explicitTags.length === 0) {
|
||||
throw new Error(
|
||||
`Missing explicit tags for ${route.method.toUpperCase()} ${route.path} in ${route.controllerFile}:${route.lineNumber}. All endpoints must use the OpenAPI middleware with explicit tags.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!route.explicitSummary) {
|
||||
throw new Error(
|
||||
`Missing explicit summary for ${route.method.toUpperCase()} ${route.path} in ${route.controllerFile}:${route.lineNumber}. All endpoints must use the OpenAPI middleware with an explicit summary.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!route.explicitOperationId) {
|
||||
throw new Error(
|
||||
`Missing explicit operationId for ${route.method.toUpperCase()} ${route.path} in ${route.controllerFile}:${route.lineNumber}. All endpoints must use the OpenAPI middleware with an explicit operationId in snake_case.`,
|
||||
);
|
||||
}
|
||||
|
||||
const security = route.explicitSecurity
|
||||
? this.buildSecurityFromExplicit(route.explicitSecurity, route)
|
||||
: this.buildSecurity(route);
|
||||
const parameters = this.buildParameters(route);
|
||||
const requestBody = this.buildRequestBody(route);
|
||||
const responses = this.buildResponses(route, route.explicitStatusCodes);
|
||||
|
||||
const operation: OpenAPIOperation = {
|
||||
operationId: this.getUniqueOperationId(route.explicitOperationId),
|
||||
summary: route.explicitSummary,
|
||||
tags: route.explicitTags,
|
||||
responses,
|
||||
'x-mint': {metadata: {title: route.explicitSummary}},
|
||||
};
|
||||
|
||||
if (route.explicitDescription) {
|
||||
operation.description = route.explicitDescription;
|
||||
}
|
||||
|
||||
if (route.explicitDeprecated) {
|
||||
operation.deprecated = route.explicitDeprecated;
|
||||
}
|
||||
|
||||
if (route.explicitExternalDocs) {
|
||||
operation.externalDocs = route.explicitExternalDocs;
|
||||
}
|
||||
|
||||
if (security.length > 0) {
|
||||
operation.security = security;
|
||||
}
|
||||
|
||||
if (parameters.length > 0) {
|
||||
operation.parameters = parameters;
|
||||
}
|
||||
|
||||
if (requestBody) {
|
||||
operation.requestBody = requestBody;
|
||||
}
|
||||
|
||||
return operation;
|
||||
}
|
||||
|
||||
private getUniqueOperationId(baseId: string): string {
|
||||
let operationId = baseId;
|
||||
let counter = 2;
|
||||
|
||||
while (this.usedOperationIds.has(operationId)) {
|
||||
operationId = `${baseId}${counter}`;
|
||||
counter++;
|
||||
}
|
||||
|
||||
this.usedOperationIds.add(operationId);
|
||||
return operationId;
|
||||
}
|
||||
|
||||
private buildSecurityFromExplicit(
|
||||
explicitSecurity: Array<string>,
|
||||
route: ExtractedRoute,
|
||||
): Array<Record<string, Array<string>>> {
|
||||
const baseSecurity = explicitSecurity.map((scheme) => ({[scheme]: []}));
|
||||
return this.applyOAuth2ScopeSecurity(baseSecurity, route);
|
||||
}
|
||||
|
||||
private buildSecurity(route: ExtractedRoute): Array<Record<string, Array<string>>> {
|
||||
let baseSecurity: Array<Record<string, Array<string>>>;
|
||||
|
||||
if (route.path.startsWith('/admin/') || route.middlewares.includes('requireAdminACL')) {
|
||||
baseSecurity = [{adminApiKey: []}];
|
||||
return this.applyOAuth2ScopeSecurity(baseSecurity, route);
|
||||
}
|
||||
|
||||
if (route.path === '/applications/@me') {
|
||||
baseSecurity = [{botToken: []}];
|
||||
return this.applyOAuth2ScopeSecurity(baseSecurity, route);
|
||||
}
|
||||
|
||||
if (route.path === '/users/@me' || route.path.startsWith('/users/@me/')) {
|
||||
baseSecurity = [{bearerToken: []}, {sessionToken: []}];
|
||||
return this.applyOAuth2ScopeSecurity(baseSecurity, route);
|
||||
}
|
||||
|
||||
const security = analyzeSecurityRequirements(route);
|
||||
if (security.type !== 'bearer') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (route.hasDefaultUserOnly) {
|
||||
baseSecurity = [{bearerToken: []}, {sessionToken: []}];
|
||||
return this.applyOAuth2ScopeSecurity(baseSecurity, route);
|
||||
}
|
||||
|
||||
baseSecurity = [{botToken: []}, {bearerToken: []}, {sessionToken: []}];
|
||||
return this.applyOAuth2ScopeSecurity(baseSecurity, route);
|
||||
}
|
||||
|
||||
private applyOAuth2ScopeSecurity(
|
||||
security: Array<Record<string, Array<string>>>,
|
||||
route: ExtractedRoute,
|
||||
): Array<Record<string, Array<string>>> {
|
||||
if (!route.oauth2RequiredScopes || route.oauth2RequiredScopes.length === 0 || !route.oauth2ScopeMode) {
|
||||
if (route.oauth2BearerTokenRequired) {
|
||||
return security.map((entry) => {
|
||||
if (!('bearerToken' in entry)) {
|
||||
return entry;
|
||||
}
|
||||
return {oauth2Token: []};
|
||||
});
|
||||
}
|
||||
return security.filter((entry) => !('bearerToken' in entry));
|
||||
}
|
||||
|
||||
if (route.oauth2ScopeMode === 'all') {
|
||||
const scopes = [...route.oauth2RequiredScopes].sort();
|
||||
return security.map((entry) => {
|
||||
if (!('bearerToken' in entry)) {
|
||||
return entry;
|
||||
}
|
||||
return {oauth2Token: scopes};
|
||||
});
|
||||
}
|
||||
|
||||
const sortedScopes = [...route.oauth2RequiredScopes].sort();
|
||||
const transformed: Array<Record<string, Array<string>>> = [];
|
||||
for (const entry of security) {
|
||||
if (!('bearerToken' in entry)) {
|
||||
transformed.push(entry);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const scope of sortedScopes) {
|
||||
transformed.push({oauth2Token: [scope]});
|
||||
}
|
||||
}
|
||||
return transformed;
|
||||
}
|
||||
|
||||
private buildParameters(route: ExtractedRoute): Array<OpenAPIParameter> {
|
||||
const parameters: Array<OpenAPIParameter> = [];
|
||||
const seenParameters = new Set<string>();
|
||||
|
||||
function addParameter(parameter: OpenAPIParameter): void {
|
||||
const key = `${parameter.in}:${parameter.name}`;
|
||||
if (seenParameters.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
seenParameters.add(key);
|
||||
parameters.push(parameter);
|
||||
}
|
||||
|
||||
for (const pathParameter of extractPathParameters(route.path)) {
|
||||
addParameter(pathParameter);
|
||||
}
|
||||
|
||||
for (const validator of route.validators) {
|
||||
if (validator.target !== 'query') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (validator.schemaName) {
|
||||
for (const parameter of this.extractQueryParametersFromSchema(validator.schemaName)) {
|
||||
addParameter(parameter);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (validator.inlineSchema) {
|
||||
for (const parameter of this.extractQueryParameters(validator)) {
|
||||
addParameter(parameter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parameters;
|
||||
}
|
||||
|
||||
private extractQueryParameters(validator: ExtractedValidator): Array<OpenAPIParameter> {
|
||||
if (!validator.inlineSchema) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const parameters: Array<OpenAPIParameter> = [];
|
||||
const matches = validator.inlineSchema.matchAll(/(\w+):\s*([^,}]+)/g);
|
||||
|
||||
for (const match of matches) {
|
||||
const name = match[1];
|
||||
const typeString = match[2].trim();
|
||||
const isOptional = typeString.includes('.optional()') || typeString.includes('.nullish()');
|
||||
|
||||
parameters.push({
|
||||
name,
|
||||
in: 'query',
|
||||
required: !isOptional,
|
||||
schema: this.inferSchemaFromTypeString(typeString),
|
||||
});
|
||||
}
|
||||
|
||||
return parameters;
|
||||
}
|
||||
|
||||
private extractQueryParametersFromSchema(schemaName: string): Array<OpenAPIParameter> {
|
||||
const loadedSchema = this.loadedSchemas.get(schemaName);
|
||||
if (!loadedSchema) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const schema = loadedSchema.openAPISchema;
|
||||
if (schema.type !== 'object' || !schema.properties) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const required = new Set(schema.required ?? []);
|
||||
const parameters: Array<OpenAPIParameter> = [];
|
||||
|
||||
for (const [name, propertySchema] of Object.entries(schema.properties)) {
|
||||
parameters.push({
|
||||
name,
|
||||
in: 'query',
|
||||
required: required.has(name),
|
||||
schema: propertySchema,
|
||||
});
|
||||
}
|
||||
|
||||
return parameters;
|
||||
}
|
||||
|
||||
private inferSchemaFromTypeString(typeString: string): OpenAPISchemaOrRef {
|
||||
function parseNumericSchema(targetTypeString: string): OpenAPISchema {
|
||||
const isInteger = targetTypeString.includes('.int()');
|
||||
const schema: OpenAPISchema = {
|
||||
type: isInteger ? 'integer' : 'number',
|
||||
};
|
||||
|
||||
const minMatch = targetTypeString.match(/\.min\((\d+)\)/);
|
||||
const maxMatch = targetTypeString.match(/\.max\((\d+)\)/);
|
||||
const defaultMatch = targetTypeString.match(/\.default\((\d+)\)/);
|
||||
|
||||
if (minMatch) {
|
||||
schema.minimum = Number.parseInt(minMatch[1], 10);
|
||||
}
|
||||
if (maxMatch) {
|
||||
schema.maximum = Number.parseInt(maxMatch[1], 10);
|
||||
}
|
||||
if (defaultMatch) {
|
||||
schema.default = Number.parseInt(defaultMatch[1], 10);
|
||||
}
|
||||
|
||||
return schema;
|
||||
}
|
||||
|
||||
if (typeString.includes('UnsignedInt64Type')) {
|
||||
return {type: 'string', format: 'int64', pattern: '^[0-9]+$'};
|
||||
}
|
||||
|
||||
if (typeString.includes('Int64StringType')) {
|
||||
return {type: 'string', format: 'int64', pattern: '^-?[0-9]+$'};
|
||||
}
|
||||
|
||||
if (typeString.includes('Int64Type')) {
|
||||
return {type: 'string', format: 'int64', pattern: '^-?[0-9]+$'};
|
||||
}
|
||||
|
||||
if (typeString.includes('PermissionStringType') || typeString.includes('BitflagStringType')) {
|
||||
return {type: 'string', format: 'int64', pattern: '^[0-9]+$'};
|
||||
}
|
||||
|
||||
if (typeString.includes('SnowflakeStringType') || typeString.includes('SnowflakeType')) {
|
||||
return SnowflakeTypeRef;
|
||||
}
|
||||
|
||||
if (typeString.includes('z.coerce.number()') || typeString.includes('z.number()')) {
|
||||
return parseNumericSchema(typeString);
|
||||
}
|
||||
|
||||
if (typeString.includes('QueryBooleanType') || typeString.includes('z.boolean()')) {
|
||||
return {type: 'boolean'};
|
||||
}
|
||||
|
||||
if (typeString.includes('z.string()')) {
|
||||
return {type: 'string'};
|
||||
}
|
||||
|
||||
return {type: 'string'};
|
||||
}
|
||||
|
||||
private getResponseSchema(schemaNameOrExpression: string): OpenAPISchema | {$ref: string} | null {
|
||||
const trimmed = schemaNameOrExpression.trim();
|
||||
const {baseExpression, isNullable} = this.stripNullability(trimmed);
|
||||
|
||||
const baseSchema = this.getBaseResponseSchema(baseExpression);
|
||||
if (!baseSchema) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isNullable) {
|
||||
return baseSchema;
|
||||
}
|
||||
|
||||
return {anyOf: [baseSchema, {type: 'null'}]};
|
||||
}
|
||||
|
||||
private getBaseResponseSchema(schemaNameOrExpression: string): OpenAPISchema | {$ref: string} | null {
|
||||
if (this.schemaRegistry.has(schemaNameOrExpression)) {
|
||||
return this.schemaRegistry.getRef(schemaNameOrExpression);
|
||||
}
|
||||
|
||||
const trimmed = schemaNameOrExpression.trim();
|
||||
if (/^z\s*\.null\(\)/.test(trimmed)) {
|
||||
return {type: 'null'};
|
||||
}
|
||||
|
||||
if (/^z\s*\.string\(\)/.test(trimmed)) {
|
||||
return {type: 'string'};
|
||||
}
|
||||
|
||||
if (/^z\s*\.array\(/.test(trimmed)) {
|
||||
const inner = this.extractFirstCallArgument(trimmed);
|
||||
if (!inner) {
|
||||
return null;
|
||||
}
|
||||
const itemSchema = this.getResponseSchema(inner);
|
||||
if (!itemSchema) {
|
||||
return null;
|
||||
}
|
||||
return {type: 'array', items: itemSchema};
|
||||
}
|
||||
|
||||
if (/^z\s*\.record\(/.test(trimmed)) {
|
||||
const args = this.extractFirstCallArgument(trimmed);
|
||||
if (!args) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [keyArg, valueArg] = this.splitTopLevel(args, ',', 2);
|
||||
if (!keyArg || !valueArg) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const valueSchema = this.getResponseSchema(valueArg);
|
||||
if (!valueSchema) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {type: 'object', additionalProperties: valueSchema};
|
||||
}
|
||||
|
||||
if (/^z\s*\.object\(/.test(trimmed)) {
|
||||
return this.parseInlineSchema(trimmed);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private stripNullability(expression: string): {baseExpression: string; isNullable: boolean} {
|
||||
let baseExpression = expression.trim();
|
||||
let isNullable = false;
|
||||
|
||||
while (true) {
|
||||
const match = baseExpression.match(/\.(nullable|nullish)\(\)\s*$/);
|
||||
if (!match) {
|
||||
break;
|
||||
}
|
||||
isNullable = true;
|
||||
baseExpression = baseExpression.slice(0, match.index).trim();
|
||||
}
|
||||
|
||||
return {baseExpression, isNullable};
|
||||
}
|
||||
|
||||
private splitTopLevel(value: string, delimiter: string, maxParts?: number): Array<string> {
|
||||
const parts: Array<string> = [];
|
||||
let start = 0;
|
||||
let parenDepth = 0;
|
||||
let braceDepth = 0;
|
||||
let bracketDepth = 0;
|
||||
let inSingleQuote = false;
|
||||
let inDoubleQuote = false;
|
||||
let inTemplate = false;
|
||||
|
||||
function pushPart(end: number): void {
|
||||
parts.push(value.slice(start, end).trim());
|
||||
start = end + delimiter.length;
|
||||
}
|
||||
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const char = value[i];
|
||||
|
||||
if (!inDoubleQuote && !inTemplate && char === "'" && value[i - 1] !== '\\') {
|
||||
inSingleQuote = !inSingleQuote;
|
||||
} else if (!inSingleQuote && !inTemplate && char === '"' && value[i - 1] !== '\\') {
|
||||
inDoubleQuote = !inDoubleQuote;
|
||||
} else if (!inSingleQuote && !inDoubleQuote && char === '`' && value[i - 1] !== '\\') {
|
||||
inTemplate = !inTemplate;
|
||||
}
|
||||
|
||||
if (inSingleQuote || inDoubleQuote || inTemplate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '(') {
|
||||
parenDepth++;
|
||||
} else if (char === ')') {
|
||||
parenDepth--;
|
||||
} else if (char === '{') {
|
||||
braceDepth++;
|
||||
} else if (char === '}') {
|
||||
braceDepth--;
|
||||
} else if (char === '[') {
|
||||
bracketDepth++;
|
||||
} else if (char === ']') {
|
||||
bracketDepth--;
|
||||
}
|
||||
|
||||
if (char === delimiter && parenDepth === 0 && braceDepth === 0 && bracketDepth === 0) {
|
||||
pushPart(i);
|
||||
if (maxParts && parts.length >= maxParts - 1) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parts.push(value.slice(start).trim());
|
||||
return parts;
|
||||
}
|
||||
|
||||
private findTopLevelChar(value: string, target: string): number {
|
||||
let parenDepth = 0;
|
||||
let braceDepth = 0;
|
||||
let bracketDepth = 0;
|
||||
let inSingleQuote = false;
|
||||
let inDoubleQuote = false;
|
||||
let inTemplate = false;
|
||||
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const char = value[i];
|
||||
|
||||
if (!inDoubleQuote && !inTemplate && char === "'" && value[i - 1] !== '\\') {
|
||||
inSingleQuote = !inSingleQuote;
|
||||
} else if (!inSingleQuote && !inTemplate && char === '"' && value[i - 1] !== '\\') {
|
||||
inDoubleQuote = !inDoubleQuote;
|
||||
} else if (!inSingleQuote && !inDoubleQuote && char === '`' && value[i - 1] !== '\\') {
|
||||
inTemplate = !inTemplate;
|
||||
}
|
||||
|
||||
if (inSingleQuote || inDoubleQuote || inTemplate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '(') {
|
||||
parenDepth++;
|
||||
} else if (char === ')') {
|
||||
parenDepth--;
|
||||
} else if (char === '{') {
|
||||
braceDepth++;
|
||||
} else if (char === '}') {
|
||||
braceDepth--;
|
||||
} else if (char === '[') {
|
||||
bracketDepth++;
|
||||
} else if (char === ']') {
|
||||
bracketDepth--;
|
||||
}
|
||||
|
||||
if (char === target && parenDepth === 0 && braceDepth === 0 && bracketDepth === 0) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
private extractFirstCallArgument(expression: string): string | null {
|
||||
const openIndex = expression.indexOf('(');
|
||||
if (openIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let depth = 0;
|
||||
for (let i = openIndex; i < expression.length; i++) {
|
||||
const char = expression[i];
|
||||
if (char === '(') {
|
||||
depth++;
|
||||
} else if (char === ')') {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
return expression.slice(openIndex + 1, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private buildRequestBody(route: ExtractedRoute): OpenAPIRequestBody | undefined {
|
||||
const jsonValidator = route.validators.find((validator) => validator.target === 'json');
|
||||
const formValidator = route.validators.find((validator) => validator.target === 'form');
|
||||
if (!jsonValidator && !formValidator) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isOptionalSchema(schema: string | null | undefined): boolean {
|
||||
if (!schema) {
|
||||
return false;
|
||||
}
|
||||
return /\.(nullable|nullish|optional)\(\)\s*$/.test(schema.trim());
|
||||
}
|
||||
|
||||
const requestBody: OpenAPIRequestBody = {
|
||||
required: !(isOptionalSchema(jsonValidator?.inlineSchema) || isOptionalSchema(formValidator?.inlineSchema)),
|
||||
content: {},
|
||||
};
|
||||
|
||||
const setContent = (
|
||||
contentType: 'application/json' | 'multipart/form-data',
|
||||
validator: ExtractedValidator,
|
||||
): void => {
|
||||
if (validator.schemaName) {
|
||||
requestBody.content[contentType] = {
|
||||
schema: this.schemaRegistry.has(validator.schemaName)
|
||||
? this.schemaRegistry.getRef(validator.schemaName)
|
||||
: {type: 'object'},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (validator.inlineSchema) {
|
||||
requestBody.content[contentType] = {
|
||||
schema: this.parseInlineSchema(validator.inlineSchema),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
if (jsonValidator) {
|
||||
setContent('application/json', jsonValidator);
|
||||
}
|
||||
|
||||
if (formValidator) {
|
||||
setContent('multipart/form-data', formValidator);
|
||||
}
|
||||
|
||||
return requestBody;
|
||||
}
|
||||
|
||||
private parseInlineSchema(schemaString: string): OpenAPISchema {
|
||||
const objectArgument = this.extractFirstCallArgument(schemaString);
|
||||
if (!objectArgument) {
|
||||
return {type: 'object'};
|
||||
}
|
||||
|
||||
const trimmed = objectArgument.trim();
|
||||
if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) {
|
||||
return {type: 'object'};
|
||||
}
|
||||
|
||||
const body = trimmed.slice(1, -1).trim();
|
||||
if (body.length === 0) {
|
||||
return {type: 'object', properties: {}};
|
||||
}
|
||||
|
||||
const properties: Record<string, OpenAPISchemaOrRef> = {};
|
||||
const required: Array<string> = [];
|
||||
|
||||
for (const entry of this.splitTopLevel(body, ',')) {
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const colonIndex = this.findTopLevelChar(entry, ':');
|
||||
if (colonIndex === -1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = entry.slice(0, colonIndex).trim();
|
||||
const value = entry.slice(colonIndex + 1).trim();
|
||||
if (!key || !value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const rawName = key.startsWith("'") || key.startsWith('"') ? key.slice(1, -1) : key;
|
||||
const name = rawName.trim();
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
properties[name] = this.inferSchemaFromTypeString(value);
|
||||
|
||||
const isOptional =
|
||||
/\.(optional|nullish)\(\)/.test(value) || /\.(default|catch)\(/.test(value) || /^z\.optional\(/.test(value);
|
||||
if (!isOptional) {
|
||||
required.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
const result: OpenAPISchema = {
|
||||
type: 'object',
|
||||
properties,
|
||||
};
|
||||
if (required.length > 0) {
|
||||
result.required = required;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private buildResponses(
|
||||
route: ExtractedRoute,
|
||||
explicitStatusCodes: Array<number> | null,
|
||||
): Record<string, OpenAPIResponse> {
|
||||
const requiresAuth = this.buildSecurity(route).length > 0;
|
||||
const responses: Record<string, OpenAPIResponse> = {};
|
||||
|
||||
const successStatusCodes =
|
||||
explicitStatusCodes && explicitStatusCodes.length > 0
|
||||
? explicitStatusCodes
|
||||
: route.successStatusCodes.length > 0
|
||||
? route.successStatusCodes
|
||||
: route.hasNoContent
|
||||
? [204]
|
||||
: [200];
|
||||
|
||||
for (const code of successStatusCodes) {
|
||||
if (code === 204 || (route.hasNoContent && !route.responseSchemaName)) {
|
||||
responses['204'] = getNoContentResponse();
|
||||
continue;
|
||||
}
|
||||
|
||||
let responseSchema: OpenAPISchema | {$ref: string} = {type: 'object'};
|
||||
if (route.responseSchemaName) {
|
||||
const resolved = this.getResponseSchema(route.responseSchemaName);
|
||||
if (resolved) {
|
||||
responseSchema = resolved;
|
||||
} else {
|
||||
console.warn(
|
||||
`Warning: Response schema '${route.responseSchemaName}' not found for ${route.method.toUpperCase()} ${route.path}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
responses[String(code)] = {
|
||||
description: 'Success',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: responseSchema,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Object.assign(responses, getErrorResponses(requiresAuth));
|
||||
return responses;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {OpenAPIPathItem, OpenAPISchema} from '@fluxer/openapi/src/OpenAPITypes';
|
||||
|
||||
const OPENAPI_SCHEMA_REF_PATTERN = /#\/components\/schemas\/([A-Za-z0-9_]+)/;
|
||||
|
||||
export function collectReferencedSchemaNames(
|
||||
paths: Record<string, OpenAPIPathItem>,
|
||||
allSchemas: Record<string, OpenAPISchema>,
|
||||
): Set<string> {
|
||||
const referenced = new Set<string>();
|
||||
|
||||
function extractRefs(value: unknown): void {
|
||||
if (value == null || typeof value !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
if ('$ref' in value && typeof (value as {$ref: string}).$ref === 'string') {
|
||||
const ref = (value as {$ref: string}).$ref;
|
||||
const match = ref.match(OPENAPI_SCHEMA_REF_PATTERN);
|
||||
|
||||
if (match) {
|
||||
const schemaName = match[1];
|
||||
if (!referenced.has(schemaName)) {
|
||||
referenced.add(schemaName);
|
||||
if (allSchemas[schemaName]) {
|
||||
extractRefs(allSchemas[schemaName]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
extractRefs(item);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (const nested of Object.values(value as Record<string, unknown>)) {
|
||||
extractRefs(nested);
|
||||
}
|
||||
}
|
||||
|
||||
extractRefs(paths);
|
||||
referenced.add('Error');
|
||||
|
||||
return referenced;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {getRegisteredBitflagSchemas, getRegisteredInt32EnumSchemas} from '@fluxer/openapi/src/converters/ZodToOpenAPI';
|
||||
import {OpenAPIGeneratorCatalog} from '@fluxer/openapi/src/generator/OpenAPIGeneratorCatalog';
|
||||
import {type LoadedSchema, loadSchemas} from '@fluxer/openapi/src/registry/SchemaLoader';
|
||||
import type {SchemaRegistry} from '@fluxer/openapi/src/registry/SchemaRegistry';
|
||||
import {CustomSchemaType} from '@fluxer/openapi/src/schemas/CustomSchemaType';
|
||||
|
||||
export interface OpenAPISchemaRegistryLoadResult {
|
||||
readonly loadedSchemas: Map<string, LoadedSchema>;
|
||||
readonly totalRegisteredSchemas: number;
|
||||
}
|
||||
|
||||
export async function loadSchemasIntoRegistry(
|
||||
basePath: string,
|
||||
schemaRegistry: SchemaRegistry,
|
||||
): Promise<OpenAPISchemaRegistryLoadResult> {
|
||||
for (const [name, schema] of OpenAPIGeneratorCatalog.builtInSchemas) {
|
||||
schemaRegistry.register(name, schema);
|
||||
}
|
||||
|
||||
for (const [name, schema] of Object.entries(CustomSchemaType.getAllSchemas())) {
|
||||
if (!schemaRegistry.has(name)) {
|
||||
schemaRegistry.register(name, schema);
|
||||
}
|
||||
}
|
||||
|
||||
let loadedSchemas = new Map<string, LoadedSchema>();
|
||||
try {
|
||||
const dynamicSchemas = await loadSchemas(basePath);
|
||||
loadedSchemas = dynamicSchemas;
|
||||
for (const [name, schema] of dynamicSchemas) {
|
||||
if (!schemaRegistry.has(name)) {
|
||||
schemaRegistry.register(name, schema.openAPISchema);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Warning: Could not load some schemas:', error);
|
||||
}
|
||||
|
||||
for (const [name, schema] of Object.entries(getRegisteredBitflagSchemas())) {
|
||||
if (!schemaRegistry.has(name)) {
|
||||
schemaRegistry.register(name, schema);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, schema] of Object.entries(getRegisteredInt32EnumSchemas())) {
|
||||
if (!schemaRegistry.has(name)) {
|
||||
schemaRegistry.register(name, schema);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loadedSchemas,
|
||||
totalRegisteredSchemas: Object.keys(schemaRegistry.getAllSchemas()).length,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user