// SPDX-License-Identifier: AGPL-3.0-or-later import {discoverControllerFiles, extractRoutesFromControllers} from '@fluxer/openapi/src/extractors/RouteExtractor'; import {isExcludedRoutePath, OpenAPIGeneratorCatalog} from '@fluxer/openapi/src/generator/OpenAPIGeneratorCatalog'; import {OpenAPIOperationBuilder} from '@fluxer/openapi/src/generator/OpenAPIOperationBuilder'; import {collectReferencedSchemaNames} from '@fluxer/openapi/src/generator/OpenAPISchemaReferenceCollector'; import {loadSchemasIntoRegistry} from '@fluxer/openapi/src/generator/OpenAPISchemaRegistryLoader'; import type { OpenAPIGenerationResult, OpenAPIGeneratorOptions, OpenAPIRouteScope, } from '@fluxer/openapi/src/OpenAPIGenerationTypes'; import type {ExtractedRoute, OpenAPIDocument, OpenAPIPathItem, OpenAPISchema} from '@fluxer/openapi/src/OpenAPITypes'; import {convertPathToOpenAPI} from '@fluxer/openapi/src/registry/ParameterRegistry'; import {SchemaRegistry} from '@fluxer/openapi/src/registry/SchemaRegistry'; interface PathBuildResult { readonly paths: Record; readonly operationCount: number; readonly skippedRouteCount: number; } interface GeneratorSettings { readonly basePath: string; readonly title: string; readonly version: string; readonly description: string; readonly serverUrl: string; readonly routeScope: OpenAPIRouteScope; } function createGeneratorSettings(options: OpenAPIGeneratorOptions): GeneratorSettings { return { basePath: options.basePath, title: options.title ?? 'Fluxer API', version: options.version ?? '1.0.0', description: options.description ?? 'The Fluxer API', serverUrl: options.serverUrl ?? 'https://api.fluxer.app', routeScope: options.routeScope ?? 'public', }; } function isAdminRoute(route: ExtractedRoute): boolean { return route.path === '/admin' || route.path.startsWith('/admin/'); } export class OpenAPIGenerator { private readonly settings: GeneratorSettings; private readonly schemaRegistry: SchemaRegistry; constructor(options: OpenAPIGeneratorOptions) { this.settings = createGeneratorSettings(options); this.schemaRegistry = new SchemaRegistry(); } public async generate(): Promise { const result = await this.generateWithStats(); return result.document; } public async generateWithStats(): Promise { const controllerFiles = discoverControllerFiles(`${this.settings.basePath}/fluxer_api`); const routes = this.filterRoutesForScope(extractRoutesFromControllers(controllerFiles)); let registeredSchemaCount = 0; let loadedSchemas = new Map(); try { const schemaLoadResult = await loadSchemasIntoRegistry(this.settings.basePath, this.schemaRegistry); registeredSchemaCount = schemaLoadResult.totalRegisteredSchemas; loadedSchemas = schemaLoadResult.loadedSchemas; } catch (error) { console.warn('Warning: Could not load some schemas:', error); registeredSchemaCount = Object.keys(this.schemaRegistry.getAllSchemas()).length; } const operationBuilder = new OpenAPIOperationBuilder({ schemaRegistry: this.schemaRegistry, loadedSchemas, usedOperationIds: new Set(), }); const pathBuildResult = this.buildPaths(routes, operationBuilder); const allSchemas = this.schemaRegistry.getAllSchemas(); const referencedSchemas = collectReferencedSchemaNames(pathBuildResult.paths, allSchemas); const publishedSchemas = this.filterPublishedSchemas(allSchemas, referencedSchemas); const tags = this.buildTags(routes); const document: OpenAPIDocument = { openapi: '3.1.0', info: { title: this.settings.title, version: this.settings.version, description: this.settings.description, contact: { name: 'Fluxer Platform AB', email: 'support@fluxer.app', }, license: { name: 'AGPL-3.0', url: 'https://www.gnu.org/licenses/agpl-3.0.html', }, }, servers: [{url: this.settings.serverUrl, description: 'Production API'}], paths: pathBuildResult.paths, components: { schemas: publishedSchemas, securitySchemes: OpenAPIGeneratorCatalog.securitySchemes, }, tags, }; return { document, stats: { controllerCount: controllerFiles.length, routeCount: routes.length, operationCount: pathBuildResult.operationCount, skippedRouteCount: pathBuildResult.skippedRouteCount, registeredSchemaCount, publishedSchemaCount: Object.keys(publishedSchemas).length, tagCount: tags.length, }, }; } private filterRoutesForScope(routes: Array): Array { switch (this.settings.routeScope) { case 'all': return routes; case 'admin': return routes.filter(isAdminRoute); case 'public': return routes.filter((route) => !isAdminRoute(route)); } } private buildPaths(routes: Array, operationBuilder: OpenAPIOperationBuilder): PathBuildResult { const paths: Record = {}; let operationCount = 0; let skippedRouteCount = 0; for (const route of routes) { if (isExcludedRoutePath(route.path)) { continue; } if (!route.responseSchemaName && !route.hasNoContent) { skippedRouteCount++; continue; } const openApiPath = convertPathToOpenAPI(route.path); paths[openApiPath] ??= {}; paths[openApiPath][route.method] = operationBuilder.buildOperation(route); operationCount++; } const sortedPaths: Record = {}; for (const key of Object.keys(paths).sort()) { sortedPaths[key] = paths[key]; } return { paths: sortedPaths, operationCount, skippedRouteCount, }; } private filterPublishedSchemas( allSchemas: Record, referencedSchemas: Set, ): Record { const publishedSchemas: Record = {}; for (const name of referencedSchemas) { if (allSchemas[name]) { publishedSchemas[name] = allSchemas[name]; } } return publishedSchemas; } private buildTags(routes: Array): Array<{ name: string; description?: string; }> { const usedTags = new Set(); for (const route of routes) { if (isExcludedRoutePath(route.path)) { continue; } if (!route.explicitTags) { continue; } for (const tag of route.explicitTags) { usedTags.add(tag); } } const orderIndex = new Map(); for (const [index, tag] of OpenAPIGeneratorCatalog.tags.order.entries()) { orderIndex.set(tag, index); } return Array.from(usedTags) .sort((a, b) => (orderIndex.get(a) ?? 1000000) - (orderIndex.get(b) ?? 1000000)) .map((name) => ({ name, description: OpenAPIGeneratorCatalog.tags.descriptions[name], })); } }