refactor progress

This commit is contained in:
Hampus Kraft
2026-02-17 12:22:36 +00:00
parent cb31608523
commit d5abd1a7e4
8257 changed files with 1190207 additions and 761040 deletions

View 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;
}

View 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;
}
}

View File

@@ -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;
}

View File

@@ -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,
};
}