/* * 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 . */ 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; readonly usedOperationIds: Set; } export class OpenAPIOperationBuilder { private readonly schemaRegistry: SchemaRegistry; private readonly loadedSchemas: Map; private readonly usedOperationIds: Set; 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, route: ExtractedRoute, ): Array>> { const baseSecurity = explicitSecurity.map((scheme) => ({[scheme]: []})); return this.applyOAuth2ScopeSecurity(baseSecurity, route); } private buildSecurity(route: ExtractedRoute): Array>> { let baseSecurity: Array>>; 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>>, route: ExtractedRoute, ): Array>> { 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>> = []; 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 { const parameters: Array = []; const seenParameters = new Set(); 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 { if (!validator.inlineSchema) { return []; } const parameters: Array = []; 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 { 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 = []; 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 { const parts: Array = []; 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 = {}; const required: Array = []; 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 | null, ): Record { const requiresAuth = this.buildSecurity(route).length > 0; const responses: Record = {}; 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; } }