211 lines
6.7 KiB
TypeScript
211 lines
6.7 KiB
TypeScript
/*
|
|
* 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 {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} 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<string, OpenAPIPathItem>;
|
|
readonly operationCount: number;
|
|
readonly skippedRouteCount: number;
|
|
}
|
|
|
|
interface GeneratorSettings {
|
|
readonly basePath: string;
|
|
readonly title: string;
|
|
readonly version: string;
|
|
readonly description: string;
|
|
readonly serverUrl: string;
|
|
}
|
|
|
|
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',
|
|
};
|
|
}
|
|
|
|
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<OpenAPIDocument> {
|
|
const result = await this.generateWithStats();
|
|
return result.document;
|
|
}
|
|
|
|
public async generateWithStats(): Promise<OpenAPIGenerationResult> {
|
|
const controllerFiles = discoverControllerFiles(`${this.settings.basePath}/packages/api`);
|
|
const routes = 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 Developers',
|
|
email: 'developers@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 buildPaths(routes: Array<ExtractedRoute>, operationBuilder: OpenAPIOperationBuilder): PathBuildResult {
|
|
const paths: Record<string, OpenAPIPathItem> = {};
|
|
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<string, OpenAPIPathItem> = {};
|
|
for (const key of Object.keys(paths).sort()) {
|
|
sortedPaths[key] = paths[key];
|
|
}
|
|
|
|
return {
|
|
paths: sortedPaths,
|
|
operationCount,
|
|
skippedRouteCount,
|
|
};
|
|
}
|
|
|
|
private filterPublishedSchemas(
|
|
allSchemas: Record<string, OpenAPISchema>,
|
|
referencedSchemas: Set<string>,
|
|
): Record<string, OpenAPISchema> {
|
|
const publishedSchemas: Record<string, OpenAPISchema> = {};
|
|
|
|
for (const name of referencedSchemas) {
|
|
if (allSchemas[name]) {
|
|
publishedSchemas[name] = allSchemas[name];
|
|
}
|
|
}
|
|
|
|
return publishedSchemas;
|
|
}
|
|
|
|
private buildTags(routes: Array<ExtractedRoute>): Array<{name: string; description?: string}> {
|
|
const usedTags = new Set<string>();
|
|
|
|
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<string, number>();
|
|
for (const [index, tag] of OpenAPIGeneratorCatalog.tags.order.entries()) {
|
|
orderIndex.set(tag, index);
|
|
}
|
|
|
|
return Array.from(usedTags)
|
|
.sort((a, b) => (orderIndex.get(a) ?? 1_000_000) - (orderIndex.get(b) ?? 1_000_000))
|
|
.map((name) => ({
|
|
name,
|
|
description: OpenAPIGeneratorCatalog.tags.descriptions[name],
|
|
}));
|
|
}
|
|
}
|