refactor progress
This commit is contained in:
26
packages/openapi/src/GenerateOpenAPISpec.tsx
Normal file
26
packages/openapi/src/GenerateOpenAPISpec.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* 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 {OpenAPIGenerationResult, OpenAPIGeneratorOptions} from '@fluxer/openapi/src/OpenAPIGenerationTypes';
|
||||
import {OpenAPIGenerator} from '@fluxer/openapi/src/OpenAPIGenerator';
|
||||
|
||||
export async function generateOpenAPISpec(options: OpenAPIGeneratorOptions): Promise<OpenAPIGenerationResult> {
|
||||
const generator = new OpenAPIGenerator(options);
|
||||
return generator.generateWithStats();
|
||||
}
|
||||
43
packages/openapi/src/OpenAPIGenerationTypes.tsx
Normal file
43
packages/openapi/src/OpenAPIGenerationTypes.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* 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 {OpenAPIDocument} from '@fluxer/openapi/src/OpenAPITypes';
|
||||
|
||||
export interface OpenAPIGeneratorOptions {
|
||||
readonly basePath: string;
|
||||
readonly title?: string;
|
||||
readonly version?: string;
|
||||
readonly description?: string;
|
||||
readonly serverUrl?: string;
|
||||
}
|
||||
|
||||
export interface OpenAPIGenerationStats {
|
||||
readonly controllerCount: number;
|
||||
readonly routeCount: number;
|
||||
readonly operationCount: number;
|
||||
readonly skippedRouteCount: number;
|
||||
readonly registeredSchemaCount: number;
|
||||
readonly publishedSchemaCount: number;
|
||||
readonly tagCount: number;
|
||||
}
|
||||
|
||||
export interface OpenAPIGenerationResult {
|
||||
readonly document: OpenAPIDocument;
|
||||
readonly stats: OpenAPIGenerationStats;
|
||||
}
|
||||
210
packages/openapi/src/OpenAPIGenerator.tsx
Normal file
210
packages/openapi/src/OpenAPIGenerator.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
/*
|
||||
* 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],
|
||||
}));
|
||||
}
|
||||
}
|
||||
212
packages/openapi/src/OpenAPITypes.tsx
Normal file
212
packages/openapi/src/OpenAPITypes.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
export type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete';
|
||||
|
||||
export type ValidatorTarget = 'json' | 'query' | 'param' | 'form' | 'header' | 'cookie';
|
||||
|
||||
export interface ExtractedValidator {
|
||||
target: ValidatorTarget;
|
||||
schemaName: string | null;
|
||||
inlineSchema: string | null;
|
||||
}
|
||||
|
||||
export interface ExtractedRoute {
|
||||
method: HttpMethod;
|
||||
path: string;
|
||||
controllerFile: string;
|
||||
lineNumber: number;
|
||||
validators: Array<ExtractedValidator>;
|
||||
middlewares: Array<string>;
|
||||
hasLoginRequired: boolean;
|
||||
hasDefaultUserOnly: boolean;
|
||||
hasLoginRequiredAllowSuspicious: boolean;
|
||||
hasSudoMode: boolean;
|
||||
rateLimitConfig: string | null;
|
||||
handlerSource: string | null;
|
||||
responseMapperName: string | null;
|
||||
responseSchemaName: string | null;
|
||||
hasNoContent: boolean;
|
||||
successStatusCodes: Array<number>;
|
||||
explicitSummary: string | null;
|
||||
explicitOperationId: string | null;
|
||||
explicitDescription: string | null;
|
||||
explicitStatusCodes: Array<number> | null;
|
||||
explicitSecurity: Array<string> | null;
|
||||
oauth2RequiredScopes: Array<string> | null;
|
||||
oauth2ScopeMode: 'all' | 'any' | null;
|
||||
oauth2BearerTokenRequired: boolean;
|
||||
explicitTags: Array<string> | null;
|
||||
explicitDeprecated: boolean;
|
||||
explicitExternalDocs: {url: string; description?: string} | null;
|
||||
}
|
||||
|
||||
export interface OpenAPIPathItem {
|
||||
[method: string]: OpenAPIOperation;
|
||||
}
|
||||
|
||||
export interface MintlifyMetadata {
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface MintlifyExtension {
|
||||
metadata?: MintlifyMetadata;
|
||||
}
|
||||
|
||||
export interface OpenAPIOperation {
|
||||
operationId: string;
|
||||
tags: Array<string>;
|
||||
summary?: string;
|
||||
description?: string;
|
||||
security?: Array<Record<string, Array<string>>>;
|
||||
parameters?: Array<OpenAPIParameter>;
|
||||
requestBody?: OpenAPIRequestBody;
|
||||
responses: Record<string, OpenAPIResponse>;
|
||||
deprecated?: boolean;
|
||||
externalDocs?: {url: string; description?: string};
|
||||
'x-mint'?: MintlifyExtension;
|
||||
}
|
||||
|
||||
export interface OpenAPIParameter {
|
||||
name: string;
|
||||
in: 'path' | 'query' | 'header' | 'cookie';
|
||||
required: boolean;
|
||||
schema: OpenAPISchemaOrRef;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface OpenAPIRequestBody {
|
||||
required?: boolean;
|
||||
content: {
|
||||
'application/json'?: {
|
||||
schema: OpenAPISchema | OpenAPIRef;
|
||||
};
|
||||
'multipart/form-data'?: {
|
||||
schema: OpenAPISchema | OpenAPIRef;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface OpenAPIResponse {
|
||||
description: string;
|
||||
content?: {
|
||||
'application/json'?: {
|
||||
schema: OpenAPISchema | OpenAPIRef;
|
||||
};
|
||||
};
|
||||
headers?: Record<string, OpenAPIHeaderObject>;
|
||||
}
|
||||
|
||||
export interface OpenAPIHeaderObject {
|
||||
description?: string;
|
||||
schema: OpenAPISchemaOrRef;
|
||||
}
|
||||
|
||||
export interface OpenAPIRef {
|
||||
$ref: string;
|
||||
}
|
||||
|
||||
export type OpenAPISchemaOrRef = OpenAPISchema | OpenAPIRef;
|
||||
|
||||
export interface OpenAPISchema {
|
||||
type?: string;
|
||||
format?: string;
|
||||
items?: OpenAPISchemaOrRef | boolean;
|
||||
prefixItems?: Array<OpenAPISchemaOrRef>;
|
||||
properties?: Record<string, OpenAPISchemaOrRef>;
|
||||
additionalProperties?: boolean | OpenAPISchemaOrRef;
|
||||
required?: Array<string>;
|
||||
enum?: Array<string | number | boolean>;
|
||||
minimum?: number;
|
||||
maximum?: number;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
minItems?: number;
|
||||
maxItems?: number;
|
||||
uniqueItems?: boolean;
|
||||
multipleOf?: number;
|
||||
exclusiveMinimum?: number;
|
||||
exclusiveMaximum?: number;
|
||||
pattern?: string;
|
||||
default?: unknown;
|
||||
nullable?: boolean;
|
||||
oneOf?: Array<OpenAPISchemaOrRef>;
|
||||
anyOf?: Array<OpenAPISchemaOrRef>;
|
||||
allOf?: Array<OpenAPISchemaOrRef>;
|
||||
not?: OpenAPISchemaOrRef;
|
||||
description?: string;
|
||||
discriminator?: {
|
||||
propertyName: string;
|
||||
mapping?: Record<string, string>;
|
||||
};
|
||||
patternProperties?: Record<string, OpenAPISchemaOrRef | boolean>;
|
||||
}
|
||||
|
||||
export interface OpenAPIDocument {
|
||||
openapi: '3.1.0';
|
||||
info: {
|
||||
title: string;
|
||||
version: string;
|
||||
description?: string;
|
||||
contact?: {
|
||||
name?: string;
|
||||
email?: string;
|
||||
url?: string;
|
||||
};
|
||||
license?: {
|
||||
name: string;
|
||||
url?: string;
|
||||
};
|
||||
};
|
||||
servers?: Array<{
|
||||
url: string;
|
||||
description?: string;
|
||||
}>;
|
||||
paths: Record<string, OpenAPIPathItem>;
|
||||
components: {
|
||||
schemas: Record<string, OpenAPISchema>;
|
||||
securitySchemes: Record<string, OpenAPISecurityScheme>;
|
||||
};
|
||||
tags?: Array<{
|
||||
name: string;
|
||||
description?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface OpenAPISecurityScheme {
|
||||
type: 'http' | 'apiKey' | 'oauth2' | 'openIdConnect';
|
||||
scheme?: string;
|
||||
bearerFormat?: string;
|
||||
name?: string;
|
||||
in?: 'header' | 'query' | 'cookie';
|
||||
description?: string;
|
||||
flows?: {
|
||||
authorizationCode?: {
|
||||
authorizationUrl: string;
|
||||
tokenUrl: string;
|
||||
refreshUrl?: string;
|
||||
scopes: Record<string, string>;
|
||||
};
|
||||
clientCredentials?: {
|
||||
tokenUrl: string;
|
||||
scopes: Record<string, string>;
|
||||
};
|
||||
};
|
||||
}
|
||||
212
packages/openapi/src/Types.tsx
Normal file
212
packages/openapi/src/Types.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
export type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete';
|
||||
|
||||
export type ValidatorTarget = 'json' | 'query' | 'param' | 'form' | 'header' | 'cookie';
|
||||
|
||||
export interface ExtractedValidator {
|
||||
target: ValidatorTarget;
|
||||
schemaName: string | null;
|
||||
inlineSchema: string | null;
|
||||
}
|
||||
|
||||
export interface ExtractedRoute {
|
||||
method: HttpMethod;
|
||||
path: string;
|
||||
controllerFile: string;
|
||||
lineNumber: number;
|
||||
validators: Array<ExtractedValidator>;
|
||||
middlewares: Array<string>;
|
||||
hasLoginRequired: boolean;
|
||||
hasDefaultUserOnly: boolean;
|
||||
hasLoginRequiredAllowSuspicious: boolean;
|
||||
hasSudoMode: boolean;
|
||||
rateLimitConfig: string | null;
|
||||
handlerSource: string | null;
|
||||
responseMapperName: string | null;
|
||||
responseSchemaName: string | null;
|
||||
hasNoContent: boolean;
|
||||
successStatusCodes: Array<number>;
|
||||
explicitSummary: string | null;
|
||||
explicitOperationId: string | null;
|
||||
explicitDescription: string | null;
|
||||
explicitStatusCodes: Array<number> | null;
|
||||
explicitSecurity: Array<string> | null;
|
||||
oauth2RequiredScopes: Array<string> | null;
|
||||
oauth2ScopeMode: 'all' | 'any' | null;
|
||||
oauth2BearerTokenRequired: boolean;
|
||||
explicitTags: Array<string> | null;
|
||||
explicitDeprecated: boolean;
|
||||
explicitExternalDocs: {url: string; description?: string} | null;
|
||||
}
|
||||
|
||||
export interface OpenAPIPathItem {
|
||||
[method: string]: OpenAPIOperation;
|
||||
}
|
||||
|
||||
export interface MintlifyMetadata {
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface MintlifyExtension {
|
||||
metadata?: MintlifyMetadata;
|
||||
}
|
||||
|
||||
export interface OpenAPIOperation {
|
||||
operationId: string;
|
||||
tags: Array<string>;
|
||||
summary?: string;
|
||||
description?: string;
|
||||
security?: Array<Record<string, Array<string>>>;
|
||||
parameters?: Array<OpenAPIParameter>;
|
||||
requestBody?: OpenAPIRequestBody;
|
||||
responses: Record<string, OpenAPIResponse>;
|
||||
deprecated?: boolean;
|
||||
externalDocs?: {url: string; description?: string};
|
||||
'x-mint'?: MintlifyExtension;
|
||||
}
|
||||
|
||||
export interface OpenAPIParameter {
|
||||
name: string;
|
||||
in: 'path' | 'query' | 'header' | 'cookie';
|
||||
required: boolean;
|
||||
schema: OpenAPISchemaOrRef;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface OpenAPIRequestBody {
|
||||
required?: boolean;
|
||||
content: {
|
||||
'application/json'?: {
|
||||
schema: OpenAPISchema | OpenAPIRef;
|
||||
};
|
||||
'multipart/form-data'?: {
|
||||
schema: OpenAPISchema | OpenAPIRef;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface OpenAPIResponse {
|
||||
description: string;
|
||||
content?: {
|
||||
'application/json'?: {
|
||||
schema: OpenAPISchema | OpenAPIRef;
|
||||
};
|
||||
};
|
||||
headers?: Record<string, OpenAPIHeaderObject>;
|
||||
}
|
||||
|
||||
export interface OpenAPIHeaderObject {
|
||||
description?: string;
|
||||
schema: OpenAPISchemaOrRef;
|
||||
}
|
||||
|
||||
export interface OpenAPIRef {
|
||||
$ref: string;
|
||||
}
|
||||
|
||||
export type OpenAPISchemaOrRef = OpenAPISchema | OpenAPIRef;
|
||||
|
||||
export interface OpenAPISchema {
|
||||
type?: string;
|
||||
format?: string;
|
||||
items?: OpenAPISchemaOrRef | boolean;
|
||||
prefixItems?: Array<OpenAPISchemaOrRef>;
|
||||
properties?: Record<string, OpenAPISchemaOrRef>;
|
||||
additionalProperties?: boolean | OpenAPISchemaOrRef;
|
||||
required?: Array<string>;
|
||||
enum?: Array<string | number | boolean>;
|
||||
minimum?: number;
|
||||
maximum?: number;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
minItems?: number;
|
||||
maxItems?: number;
|
||||
uniqueItems?: boolean;
|
||||
multipleOf?: number;
|
||||
exclusiveMinimum?: number;
|
||||
exclusiveMaximum?: number;
|
||||
pattern?: string;
|
||||
default?: unknown;
|
||||
nullable?: boolean;
|
||||
oneOf?: Array<OpenAPISchemaOrRef>;
|
||||
anyOf?: Array<OpenAPISchemaOrRef>;
|
||||
allOf?: Array<OpenAPISchemaOrRef>;
|
||||
not?: OpenAPISchemaOrRef;
|
||||
description?: string;
|
||||
discriminator?: {
|
||||
propertyName: string;
|
||||
mapping?: Record<string, string>;
|
||||
};
|
||||
patternProperties?: Record<string, OpenAPISchemaOrRef | boolean>;
|
||||
}
|
||||
|
||||
export interface OpenAPIDocument {
|
||||
openapi: '3.1.0';
|
||||
info: {
|
||||
title: string;
|
||||
version: string;
|
||||
description?: string;
|
||||
contact?: {
|
||||
name?: string;
|
||||
email?: string;
|
||||
url?: string;
|
||||
};
|
||||
license?: {
|
||||
name: string;
|
||||
url?: string;
|
||||
};
|
||||
};
|
||||
servers?: Array<{
|
||||
url: string;
|
||||
description?: string;
|
||||
}>;
|
||||
paths: Record<string, OpenAPIPathItem>;
|
||||
components: {
|
||||
schemas: Record<string, OpenAPISchema>;
|
||||
securitySchemes: Record<string, OpenAPISecurityScheme>;
|
||||
};
|
||||
tags?: Array<{
|
||||
name: string;
|
||||
description?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface OpenAPISecurityScheme {
|
||||
type: 'http' | 'apiKey' | 'oauth2' | 'openIdConnect';
|
||||
scheme?: string;
|
||||
bearerFormat?: string;
|
||||
name?: string;
|
||||
in?: 'header' | 'query' | 'cookie';
|
||||
description?: string;
|
||||
flows?: {
|
||||
authorizationCode?: {
|
||||
authorizationUrl: string;
|
||||
tokenUrl: string;
|
||||
refreshUrl?: string;
|
||||
scopes: Record<string, string>;
|
||||
};
|
||||
clientCredentials?: {
|
||||
tokenUrl: string;
|
||||
scopes: Record<string, string>;
|
||||
};
|
||||
};
|
||||
}
|
||||
231
packages/openapi/src/converters/BuiltInSchemas.tsx
Normal file
231
packages/openapi/src/converters/BuiltInSchemas.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
/*
|
||||
* 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 {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {APIErrorCodesDescriptions} from '@fluxer/constants/src/ApiErrorCodesDescriptions';
|
||||
import type {OpenAPIRef, OpenAPISchema} from '@fluxer/openapi/src/Types';
|
||||
|
||||
export const SnowflakeTypeSchema: OpenAPISchema = {
|
||||
type: 'string',
|
||||
pattern: '^(0|[1-9][0-9]*)$',
|
||||
format: 'snowflake',
|
||||
};
|
||||
|
||||
export const SnowflakeTypeRef: OpenAPIRef = {$ref: '#/components/schemas/SnowflakeType'};
|
||||
|
||||
export const Int32TypeSchema: OpenAPISchema = {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
maximum: 2147483647,
|
||||
format: 'int32',
|
||||
};
|
||||
|
||||
export const Int32TypeRef: OpenAPIRef = {$ref: '#/components/schemas/Int32Type'};
|
||||
|
||||
export const Int64TypeSchema: OpenAPISchema = {
|
||||
type: 'string',
|
||||
format: 'int64',
|
||||
pattern: '^-?[0-9]+$',
|
||||
};
|
||||
|
||||
export const Int64TypeRef: OpenAPIRef = {$ref: '#/components/schemas/Int64Type'};
|
||||
|
||||
export const Int64StringTypeSchema: OpenAPISchema = {
|
||||
type: 'string',
|
||||
format: 'int64',
|
||||
pattern: '^-?[0-9]+$',
|
||||
};
|
||||
|
||||
export const Int64StringTypeRef: OpenAPIRef = {$ref: '#/components/schemas/Int64StringType'};
|
||||
|
||||
export const UnsignedInt64TypeSchema: OpenAPISchema = {
|
||||
type: 'string',
|
||||
format: 'int64',
|
||||
pattern: '^[0-9]+$',
|
||||
};
|
||||
|
||||
export const UnsignedInt64TypeRef: OpenAPIRef = {$ref: '#/components/schemas/UnsignedInt64Type'};
|
||||
|
||||
export const UsernameTypeSchema: OpenAPISchema = {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
maxLength: 32,
|
||||
pattern: '^[a-zA-Z0-9_]+$',
|
||||
};
|
||||
|
||||
export const UsernameTypeRef: OpenAPIRef = {$ref: '#/components/schemas/UsernameType'};
|
||||
|
||||
export const EmailTypeSchema: OpenAPISchema = {
|
||||
type: 'string',
|
||||
format: 'email',
|
||||
};
|
||||
|
||||
export const EmailTypeRef: OpenAPIRef = {$ref: '#/components/schemas/EmailType'};
|
||||
|
||||
export const PasswordTypeSchema: OpenAPISchema = {
|
||||
type: 'string',
|
||||
minLength: 8,
|
||||
maxLength: 256,
|
||||
};
|
||||
|
||||
export const PasswordTypeRef: OpenAPIRef = {$ref: '#/components/schemas/PasswordType'};
|
||||
|
||||
export const PhoneNumberTypeSchema: OpenAPISchema = {
|
||||
type: 'string',
|
||||
pattern: '^\\+[1-9]\\d{1,14}$',
|
||||
};
|
||||
|
||||
export const PhoneNumberTypeRef: OpenAPIRef = {$ref: '#/components/schemas/PhoneNumberType'};
|
||||
|
||||
export const Base64ImageTypeSchema: OpenAPISchema = {
|
||||
type: 'string',
|
||||
format: 'byte',
|
||||
description: 'Base64-encoded image data',
|
||||
};
|
||||
|
||||
export const Base64ImageTypeRef: OpenAPIRef = {$ref: '#/components/schemas/Base64ImageType'};
|
||||
|
||||
export const LocaleSchema = {
|
||||
type: 'string',
|
||||
enum: [
|
||||
'ar',
|
||||
'bg',
|
||||
'cs',
|
||||
'da',
|
||||
'de',
|
||||
'el',
|
||||
'en-GB',
|
||||
'en-US',
|
||||
'es-ES',
|
||||
'es-419',
|
||||
'fi',
|
||||
'fr',
|
||||
'he',
|
||||
'hi',
|
||||
'hr',
|
||||
'hu',
|
||||
'id',
|
||||
'it',
|
||||
'ja',
|
||||
'ko',
|
||||
'lt',
|
||||
'nl',
|
||||
'no',
|
||||
'pl',
|
||||
'pt-BR',
|
||||
'ro',
|
||||
'ru',
|
||||
'sv-SE',
|
||||
'th',
|
||||
'tr',
|
||||
'uk',
|
||||
'vi',
|
||||
'zh-CN',
|
||||
'zh-TW',
|
||||
],
|
||||
'x-enumNames': [
|
||||
'AR',
|
||||
'BG',
|
||||
'CS',
|
||||
'DA',
|
||||
'DE',
|
||||
'EL',
|
||||
'EN_GB',
|
||||
'EN_US',
|
||||
'ES_ES',
|
||||
'ES_419',
|
||||
'FI',
|
||||
'FR',
|
||||
'HE',
|
||||
'HI',
|
||||
'HR',
|
||||
'HU',
|
||||
'ID',
|
||||
'IT',
|
||||
'JA',
|
||||
'KO',
|
||||
'LT',
|
||||
'NL',
|
||||
'NO',
|
||||
'PL',
|
||||
'PT_BR',
|
||||
'RO',
|
||||
'RU',
|
||||
'SV_SE',
|
||||
'TH',
|
||||
'TR',
|
||||
'UK',
|
||||
'VI',
|
||||
'ZH_CN',
|
||||
'ZH_TW',
|
||||
],
|
||||
'x-enumDescriptions': [
|
||||
'Arabic',
|
||||
'Bulgarian',
|
||||
'Czech',
|
||||
'Danish',
|
||||
'German',
|
||||
'Greek',
|
||||
'English (United Kingdom)',
|
||||
'English (United States)',
|
||||
'Spanish (Spain)',
|
||||
'Spanish (Latin America)',
|
||||
'Finnish',
|
||||
'French',
|
||||
'Hebrew',
|
||||
'Hindi',
|
||||
'Croatian',
|
||||
'Hungarian',
|
||||
'Indonesian',
|
||||
'Italian',
|
||||
'Japanese',
|
||||
'Korean',
|
||||
'Lithuanian',
|
||||
'Dutch',
|
||||
'Norwegian',
|
||||
'Polish',
|
||||
'Portuguese (Brazil)',
|
||||
'Romanian',
|
||||
'Russian',
|
||||
'Swedish',
|
||||
'Thai',
|
||||
'Turkish',
|
||||
'Ukrainian',
|
||||
'Vietnamese',
|
||||
'Chinese (Simplified)',
|
||||
'Chinese (Traditional)',
|
||||
],
|
||||
description: 'The locale code for the user interface language',
|
||||
} as const satisfies OpenAPISchema & Record<string, unknown>;
|
||||
|
||||
export const LocaleRef: OpenAPIRef = {$ref: '#/components/schemas/Locale'};
|
||||
|
||||
const apiErrorCodeValues = Object.values(APIErrorCodes);
|
||||
const apiErrorCodeDescriptions = Object.keys(APIErrorCodes).map(
|
||||
(key) => APIErrorCodesDescriptions[key as keyof typeof APIErrorCodesDescriptions] ?? '',
|
||||
);
|
||||
|
||||
export const APIErrorCodeSchema = {
|
||||
type: 'string',
|
||||
enum: apiErrorCodeValues,
|
||||
'x-enumDescriptions': apiErrorCodeDescriptions,
|
||||
description: 'Error codes returned by API operations',
|
||||
} as const satisfies OpenAPISchema & Record<string, unknown>;
|
||||
|
||||
export const APIErrorCodeRef: OpenAPIRef = {$ref: '#/components/schemas/APIErrorCode'};
|
||||
1850
packages/openapi/src/converters/ZodToOpenAPI.tsx
Normal file
1850
packages/openapi/src/converters/ZodToOpenAPI.tsx
Normal file
File diff suppressed because it is too large
Load Diff
93
packages/openapi/src/extractors/MiddlewareAnalyzer.tsx
Normal file
93
packages/openapi/src/extractors/MiddlewareAnalyzer.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* 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 {ExtractedRoute} from '@fluxer/openapi/src/Types';
|
||||
|
||||
export interface SecurityRequirement {
|
||||
type: 'bearer' | 'none';
|
||||
scopes?: Array<string>;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface RateLimitInfo {
|
||||
bucket: string;
|
||||
configName: string;
|
||||
}
|
||||
|
||||
export function analyzeSecurityRequirements(route: ExtractedRoute): SecurityRequirement {
|
||||
if (route.hasLoginRequired || route.hasLoginRequiredAllowSuspicious || route.hasDefaultUserOnly) {
|
||||
return {
|
||||
type: 'bearer',
|
||||
description: route.hasDefaultUserOnly
|
||||
? 'Requires authentication (user accounts only, no bots)'
|
||||
: route.hasLoginRequiredAllowSuspicious
|
||||
? 'Requires authentication (allows accounts with suspicious activity flags)'
|
||||
: 'Requires authentication',
|
||||
};
|
||||
}
|
||||
|
||||
return {type: 'none'};
|
||||
}
|
||||
|
||||
export function analyzeRateLimitConfig(route: ExtractedRoute): RateLimitInfo | null {
|
||||
if (!route.rateLimitConfig) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const configText = route.rateLimitConfig;
|
||||
|
||||
const match = configText.match(/RateLimitConfigs\.(\w+)/);
|
||||
if (match) {
|
||||
const configName = match[1];
|
||||
return {
|
||||
bucket: configNameToBucket(configName),
|
||||
configName,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
bucket: 'unknown',
|
||||
configName: configText,
|
||||
};
|
||||
}
|
||||
|
||||
function configNameToBucket(configName: string): string {
|
||||
return configName
|
||||
.replace(/_/g, ':')
|
||||
.toLowerCase()
|
||||
.replace(/^(\w+):(\w+)$/, '$1:$2');
|
||||
}
|
||||
|
||||
export function requiresSudoMode(route: ExtractedRoute): boolean {
|
||||
return route.hasSudoMode;
|
||||
}
|
||||
|
||||
export function getRouteDescription(route: ExtractedRoute): string {
|
||||
const parts: Array<string> = [];
|
||||
|
||||
if (route.hasSudoMode) {
|
||||
parts.push('Requires sudo mode verification.');
|
||||
}
|
||||
|
||||
if (route.hasDefaultUserOnly) {
|
||||
parts.push('Only available to user accounts (not bots).');
|
||||
}
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
745
packages/openapi/src/extractors/RouteExtractor.tsx
Normal file
745
packages/openapi/src/extractors/RouteExtractor.tsx
Normal file
@@ -0,0 +1,745 @@
|
||||
/*
|
||||
* 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 {ExtractedRoute, ExtractedValidator, HttpMethod, ValidatorTarget} from '@fluxer/openapi/src/Types';
|
||||
import {type CallExpression, Node, Project, type SourceFile} from 'ts-morph';
|
||||
|
||||
const HTTP_METHODS: ReadonlySet<string> = new Set(['get', 'post', 'put', 'patch', 'delete']);
|
||||
|
||||
function isHttpMethod(method: string): method is HttpMethod {
|
||||
return HTTP_METHODS.has(method);
|
||||
}
|
||||
|
||||
function isValidatorTarget(target: string): target is ValidatorTarget {
|
||||
return ['json', 'query', 'param', 'form', 'header', 'cookie'].includes(target);
|
||||
}
|
||||
|
||||
function extractStringLiteral(node: Node): string | null {
|
||||
if (Node.isStringLiteral(node)) {
|
||||
return node.getLiteralValue();
|
||||
}
|
||||
if (Node.isNoSubstitutionTemplateLiteral(node)) {
|
||||
return node.getLiteralValue();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractNumberArray(value: unknown): Array<number> | null {
|
||||
if (typeof value === 'number') return [value];
|
||||
if (Array.isArray(value)) {
|
||||
const numbers = value.filter((v): v is number => typeof v === 'number');
|
||||
return numbers.length > 0 ? numbers : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractStringArray(value: unknown): Array<string> | null {
|
||||
if (typeof value === 'string') return [value];
|
||||
if (Array.isArray(value)) {
|
||||
const strings = value.filter((v): v is string => typeof v === 'string');
|
||||
return strings.length > 0 ? strings : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractOAuth2ScopeArgs(args: ReadonlyArray<Node>): Array<string> | null {
|
||||
const scopes: Array<string> = [];
|
||||
for (const arg of args) {
|
||||
const value = extractStringLiteral(arg);
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
scopes.push(value);
|
||||
}
|
||||
|
||||
return scopes.length > 0 ? scopes : null;
|
||||
}
|
||||
|
||||
function extractObjectLiteralValue(node: Node): unknown {
|
||||
if (Node.isStringLiteral(node) || Node.isNoSubstitutionTemplateLiteral(node)) {
|
||||
return node.getLiteralValue();
|
||||
}
|
||||
if (Node.isNumericLiteral(node)) {
|
||||
return Number.parseFloat(node.getText());
|
||||
}
|
||||
if (Node.isTrueLiteral(node)) {
|
||||
return true;
|
||||
}
|
||||
if (Node.isFalseLiteral(node)) {
|
||||
return false;
|
||||
}
|
||||
if (Node.isNullLiteral(node)) {
|
||||
return null;
|
||||
}
|
||||
if (Node.isIdentifier(node)) {
|
||||
return node.getText();
|
||||
}
|
||||
if (Node.isPropertyAccessExpression(node)) {
|
||||
return node.getText();
|
||||
}
|
||||
if (Node.isCallExpression(node)) {
|
||||
return node.getText();
|
||||
}
|
||||
if (Node.isArrayLiteralExpression(node)) {
|
||||
return node.getElements().map((el) => extractObjectLiteralValue(el));
|
||||
}
|
||||
if (Node.isObjectLiteralExpression(node)) {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const prop of node.getProperties()) {
|
||||
if (Node.isPropertyAssignment(prop)) {
|
||||
const key = prop.getName();
|
||||
const initializer = prop.getInitializer();
|
||||
if (initializer) {
|
||||
result[key] = extractObjectLiteralValue(initializer);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseObjectLiteralMetadata(objLiteral: Node): Record<string, unknown> {
|
||||
if (!Node.isObjectLiteralExpression(objLiteral)) return {};
|
||||
return extractObjectLiteralValue(objLiteral) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function extractValidatorInfo(callExpr: CallExpression): ExtractedValidator | null {
|
||||
const expression = callExpr.getExpression();
|
||||
if (!Node.isIdentifier(expression) || expression.getText() !== 'Validator') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const args = callExpr.getArguments();
|
||||
if (args.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetArg = args[0];
|
||||
const schemaArg = args[1];
|
||||
|
||||
const target = extractStringLiteral(targetArg);
|
||||
if (!target || !isValidatorTarget(target)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let schemaName: string | null = null;
|
||||
let inlineSchema: string | null = null;
|
||||
|
||||
if (Node.isIdentifier(schemaArg)) {
|
||||
schemaName = schemaArg.getText();
|
||||
} else if (Node.isCallExpression(schemaArg)) {
|
||||
const callText = schemaArg.getText();
|
||||
if (callText.startsWith('z.object')) {
|
||||
inlineSchema = callText;
|
||||
} else {
|
||||
const callExpressionName = schemaArg.getExpression();
|
||||
if (Node.isPropertyAccessExpression(callExpressionName)) {
|
||||
const propName = callExpressionName.getName();
|
||||
if (propName === 'merge' || propName === 'pick' || propName === 'omit' || propName === 'partial') {
|
||||
const obj = callExpressionName.getExpression();
|
||||
if (Node.isIdentifier(obj)) {
|
||||
schemaName = obj.getText();
|
||||
} else {
|
||||
inlineSchema = callText;
|
||||
}
|
||||
} else {
|
||||
inlineSchema = callText;
|
||||
}
|
||||
} else {
|
||||
inlineSchema = callText;
|
||||
}
|
||||
}
|
||||
} else if (Node.isPropertyAccessExpression(schemaArg)) {
|
||||
schemaName = schemaArg.getText();
|
||||
} else {
|
||||
inlineSchema = schemaArg.getText();
|
||||
}
|
||||
|
||||
return {target, schemaName, inlineSchema};
|
||||
}
|
||||
|
||||
interface MiddlewareInfo {
|
||||
middlewareName: string;
|
||||
rateLimitConfig: string | null;
|
||||
responseSchemaName: string | null;
|
||||
hasNoContent: boolean;
|
||||
explicitSummary: string | null;
|
||||
explicitOperationId: string | null;
|
||||
explicitDescription: string | null;
|
||||
explicitStatusCodes: Array<number> | null;
|
||||
explicitSecurity: Array<string> | null;
|
||||
oauth2RequiredScopes: Array<string> | null;
|
||||
oauth2ScopeMode: 'all' | 'any' | null;
|
||||
oauth2BearerTokenRequired: boolean;
|
||||
explicitTags: Array<string> | null;
|
||||
explicitDeprecated: boolean;
|
||||
explicitExternalDocs: {url: string; description?: string} | null;
|
||||
}
|
||||
|
||||
function extractMiddlewareInfo(callExpr: CallExpression): MiddlewareInfo | null {
|
||||
const expression = callExpr.getExpression();
|
||||
|
||||
if (Node.isIdentifier(expression)) {
|
||||
const name = expression.getText();
|
||||
|
||||
if (name === 'RateLimitMiddleware') {
|
||||
const args = callExpr.getArguments();
|
||||
if (args.length > 0) {
|
||||
const configArg = args[0];
|
||||
const configText = configArg.getText();
|
||||
return {
|
||||
middlewareName: name,
|
||||
rateLimitConfig: configText,
|
||||
responseSchemaName: null,
|
||||
hasNoContent: false,
|
||||
explicitSummary: null,
|
||||
explicitOperationId: null,
|
||||
explicitDescription: null,
|
||||
explicitStatusCodes: null,
|
||||
explicitSecurity: null,
|
||||
oauth2RequiredScopes: null,
|
||||
oauth2ScopeMode: null,
|
||||
oauth2BearerTokenRequired: false,
|
||||
explicitTags: null,
|
||||
explicitDeprecated: false,
|
||||
explicitExternalDocs: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (name === 'ResponseType') {
|
||||
const args = callExpr.getArguments();
|
||||
if (args.length > 0) {
|
||||
const schemaArg = args[0];
|
||||
let schemaName: string | null = null;
|
||||
if (Node.isIdentifier(schemaArg)) {
|
||||
schemaName = schemaArg.getText();
|
||||
} else if (Node.isPropertyAccessExpression(schemaArg)) {
|
||||
schemaName = schemaArg.getText();
|
||||
} else if (Node.isCallExpression(schemaArg)) {
|
||||
schemaName = schemaArg.getText();
|
||||
}
|
||||
return {
|
||||
middlewareName: name,
|
||||
rateLimitConfig: null,
|
||||
responseSchemaName: schemaName,
|
||||
hasNoContent: false,
|
||||
explicitSummary: null,
|
||||
explicitOperationId: null,
|
||||
explicitDescription: null,
|
||||
explicitStatusCodes: null,
|
||||
explicitSecurity: null,
|
||||
oauth2RequiredScopes: null,
|
||||
oauth2ScopeMode: null,
|
||||
oauth2BearerTokenRequired: false,
|
||||
explicitTags: null,
|
||||
explicitDeprecated: false,
|
||||
explicitExternalDocs: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (name === 'NoContent') {
|
||||
return {
|
||||
middlewareName: name,
|
||||
rateLimitConfig: null,
|
||||
responseSchemaName: null,
|
||||
hasNoContent: true,
|
||||
explicitSummary: null,
|
||||
explicitOperationId: null,
|
||||
explicitDescription: null,
|
||||
explicitStatusCodes: null,
|
||||
explicitSecurity: null,
|
||||
oauth2RequiredScopes: null,
|
||||
oauth2ScopeMode: null,
|
||||
oauth2BearerTokenRequired: false,
|
||||
explicitTags: null,
|
||||
explicitDeprecated: false,
|
||||
explicitExternalDocs: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (name === 'OpenAPI') {
|
||||
const args = callExpr.getArguments();
|
||||
if (args.length === 0) return null;
|
||||
|
||||
const firstArg = args[0];
|
||||
|
||||
if (Node.isObjectLiteralExpression(firstArg)) {
|
||||
const metadata = parseObjectLiteralMetadata(firstArg);
|
||||
const operationId = typeof metadata.operationId === 'string' ? metadata.operationId : null;
|
||||
const summary = typeof metadata.summary === 'string' ? metadata.summary : null;
|
||||
const description = typeof metadata.description === 'string' ? metadata.description : null;
|
||||
const deprecated = typeof metadata.deprecated === 'boolean' ? metadata.deprecated : false;
|
||||
|
||||
let schemaName: string | null = null;
|
||||
if (metadata.responseSchema != null) {
|
||||
schemaName = String(metadata.responseSchema);
|
||||
}
|
||||
|
||||
const statusCodes = extractNumberArray(metadata.statusCode);
|
||||
const security = extractStringArray(metadata.security);
|
||||
const tags = extractStringArray(metadata.tags);
|
||||
|
||||
let externalDocs: {url: string; description?: string} | null = null;
|
||||
if (
|
||||
metadata.externalDocs &&
|
||||
typeof metadata.externalDocs === 'object' &&
|
||||
'url' in metadata.externalDocs &&
|
||||
typeof metadata.externalDocs.url === 'string'
|
||||
) {
|
||||
externalDocs = {
|
||||
url: metadata.externalDocs.url,
|
||||
description:
|
||||
'description' in metadata.externalDocs && typeof metadata.externalDocs.description === 'string'
|
||||
? metadata.externalDocs.description
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
middlewareName: name,
|
||||
rateLimitConfig: null,
|
||||
responseSchemaName: schemaName,
|
||||
hasNoContent: schemaName === null || schemaName === 'null',
|
||||
explicitSummary: summary,
|
||||
explicitOperationId: operationId,
|
||||
explicitDescription: description,
|
||||
explicitStatusCodes: statusCodes,
|
||||
explicitSecurity: security,
|
||||
oauth2RequiredScopes: null,
|
||||
oauth2ScopeMode: null,
|
||||
oauth2BearerTokenRequired: false,
|
||||
explicitTags: tags,
|
||||
explicitDeprecated: deprecated,
|
||||
explicitExternalDocs: externalDocs,
|
||||
};
|
||||
}
|
||||
|
||||
if (args.length >= 2) {
|
||||
const secondArg = args[1];
|
||||
let operationId: string | null = null;
|
||||
let summary: string | null = null;
|
||||
let schemaName: string | null = null;
|
||||
let description: string | null = null;
|
||||
|
||||
if (Node.isStringLiteral(firstArg) || Node.isNoSubstitutionTemplateLiteral(firstArg)) {
|
||||
operationId = firstArg.getLiteralValue();
|
||||
}
|
||||
|
||||
if (Node.isStringLiteral(secondArg) || Node.isNoSubstitutionTemplateLiteral(secondArg)) {
|
||||
summary = secondArg.getLiteralValue();
|
||||
}
|
||||
|
||||
if (args.length > 2) {
|
||||
const thirdArg = args[2];
|
||||
if (Node.isIdentifier(thirdArg)) {
|
||||
schemaName = thirdArg.getText();
|
||||
} else if (Node.isPropertyAccessExpression(thirdArg)) {
|
||||
schemaName = thirdArg.getText();
|
||||
} else if (Node.isCallExpression(thirdArg)) {
|
||||
schemaName = thirdArg.getText();
|
||||
}
|
||||
}
|
||||
|
||||
if (args.length > 3) {
|
||||
const fourthArg = args[3];
|
||||
if (Node.isObjectLiteralExpression(fourthArg)) {
|
||||
const properties = fourthArg.getProperties();
|
||||
for (const prop of properties) {
|
||||
if (Node.isPropertyAssignment(prop)) {
|
||||
const propName = prop.getName();
|
||||
if (propName === 'description') {
|
||||
const initializer = prop.getInitializer();
|
||||
if (initializer) {
|
||||
description = extractStringLiteral(initializer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
middlewareName: name,
|
||||
rateLimitConfig: null,
|
||||
responseSchemaName: schemaName,
|
||||
hasNoContent: schemaName === null || schemaName === 'null',
|
||||
explicitSummary: summary,
|
||||
explicitOperationId: operationId,
|
||||
explicitDescription: description,
|
||||
explicitStatusCodes: null,
|
||||
explicitSecurity: null,
|
||||
oauth2RequiredScopes: null,
|
||||
oauth2ScopeMode: null,
|
||||
oauth2BearerTokenRequired: false,
|
||||
explicitTags: null,
|
||||
explicitDeprecated: false,
|
||||
explicitExternalDocs: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (name === 'requireOAuth2Scope' || name === 'requireOAuth2ScopeForBearer') {
|
||||
const scopes = extractOAuth2ScopeArgs(callExpr.getArguments());
|
||||
return {
|
||||
middlewareName: name,
|
||||
rateLimitConfig: null,
|
||||
responseSchemaName: null,
|
||||
hasNoContent: false,
|
||||
explicitSummary: null,
|
||||
explicitOperationId: null,
|
||||
explicitDescription: null,
|
||||
explicitStatusCodes: null,
|
||||
explicitSecurity: null,
|
||||
oauth2RequiredScopes: scopes,
|
||||
oauth2ScopeMode: 'all',
|
||||
oauth2BearerTokenRequired: false,
|
||||
explicitTags: null,
|
||||
explicitDeprecated: false,
|
||||
explicitExternalDocs: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (name === 'requireAnyOAuth2Scope' || name === 'requireAnyOAuth2ScopeForBearer') {
|
||||
const scopes = extractOAuth2ScopeArgs(callExpr.getArguments());
|
||||
return {
|
||||
middlewareName: name,
|
||||
rateLimitConfig: null,
|
||||
responseSchemaName: null,
|
||||
hasNoContent: false,
|
||||
explicitSummary: null,
|
||||
explicitOperationId: null,
|
||||
explicitDescription: null,
|
||||
explicitStatusCodes: null,
|
||||
explicitSecurity: null,
|
||||
oauth2RequiredScopes: scopes,
|
||||
oauth2ScopeMode: 'any',
|
||||
oauth2BearerTokenRequired: false,
|
||||
explicitTags: null,
|
||||
explicitDeprecated: false,
|
||||
explicitExternalDocs: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (name === 'requireOAuth2BearerToken') {
|
||||
return {
|
||||
middlewareName: name,
|
||||
rateLimitConfig: null,
|
||||
responseSchemaName: null,
|
||||
hasNoContent: false,
|
||||
explicitSummary: null,
|
||||
explicitOperationId: null,
|
||||
explicitDescription: null,
|
||||
explicitStatusCodes: null,
|
||||
explicitSecurity: null,
|
||||
oauth2RequiredScopes: null,
|
||||
oauth2ScopeMode: null,
|
||||
oauth2BearerTokenRequired: true,
|
||||
explicitTags: null,
|
||||
explicitDeprecated: false,
|
||||
explicitExternalDocs: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
middlewareName: name,
|
||||
rateLimitConfig: null,
|
||||
responseSchemaName: null,
|
||||
hasNoContent: false,
|
||||
explicitSummary: null,
|
||||
explicitOperationId: null,
|
||||
explicitDescription: null,
|
||||
explicitStatusCodes: null,
|
||||
explicitSecurity: null,
|
||||
oauth2RequiredScopes: null,
|
||||
oauth2ScopeMode: null,
|
||||
oauth2BearerTokenRequired: false,
|
||||
explicitTags: null,
|
||||
explicitDeprecated: false,
|
||||
explicitExternalDocs: null,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractHandlerInfo(
|
||||
arg: Node,
|
||||
): {handlerSource: string; responseMapperName: string | null; successStatusCodes: Array<number>} | null {
|
||||
if (!Node.isArrowFunction(arg) && !Node.isFunctionExpression(arg)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handlerSource = arg.getText();
|
||||
|
||||
let responseMapperName: string | null = null;
|
||||
const mapperMatch = handlerSource.match(/\b(map\w+To\w+)\s*\(/);
|
||||
if (mapperMatch) {
|
||||
responseMapperName = mapperMatch[1];
|
||||
}
|
||||
|
||||
const successStatusCodes = extractSuccessStatusCodes(arg);
|
||||
|
||||
const truncatedSource =
|
||||
handlerSource.length > 2000 ? `${handlerSource.slice(0, 2000)}\n// ... truncated` : handlerSource;
|
||||
|
||||
return {handlerSource: truncatedSource, responseMapperName, successStatusCodes};
|
||||
}
|
||||
|
||||
function extractSuccessStatusCodes(handler: Node): Array<number> {
|
||||
const codes = new Set<number>();
|
||||
|
||||
handler.forEachDescendant((node) => {
|
||||
if (!Node.isCallExpression(node)) return;
|
||||
|
||||
const expression = node.getExpression();
|
||||
if (!Node.isPropertyAccessExpression(expression)) return;
|
||||
|
||||
const target = expression.getExpression();
|
||||
if (!Node.isIdentifier(target) || target.getText() !== 'ctx') return;
|
||||
|
||||
const method = expression.getName();
|
||||
if (method !== 'json' && method !== 'body' && method !== 'text') return;
|
||||
|
||||
const args = node.getArguments();
|
||||
if (args.length < 2) return;
|
||||
|
||||
const statusArg = args[1];
|
||||
if (!Node.isNumericLiteral(statusArg)) return;
|
||||
|
||||
const parsed = Number.parseInt(statusArg.getText(), 10);
|
||||
if (!Number.isFinite(parsed)) return;
|
||||
if (parsed >= 200 && parsed <= 299) {
|
||||
codes.add(parsed);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(codes).sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function extractRouteFromCall(callExpr: CallExpression, sourceFile: SourceFile): ExtractedRoute | null {
|
||||
const expression = callExpr.getExpression();
|
||||
if (!Node.isPropertyAccessExpression(expression)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const method = expression.getName().toLowerCase();
|
||||
if (!isHttpMethod(method)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const args = callExpr.getArguments();
|
||||
if (args.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pathArg = args[0];
|
||||
const path = extractStringLiteral(pathArg);
|
||||
if (!path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const validators: Array<ExtractedValidator> = [];
|
||||
const middlewares: Array<string> = [];
|
||||
let hasLoginRequired = false;
|
||||
let hasDefaultUserOnly = false;
|
||||
let hasLoginRequiredAllowSuspicious = false;
|
||||
let hasSudoMode = false;
|
||||
let rateLimitConfig: string | null = null;
|
||||
let handlerSource: string | null = null;
|
||||
let responseMapperName: string | null = null;
|
||||
let responseSchemaName: string | null = null;
|
||||
let hasNoContent = false;
|
||||
let successStatusCodes: Array<number> = [];
|
||||
let explicitSummary: string | null = null;
|
||||
let explicitOperationId: string | null = null;
|
||||
let explicitDescription: string | null = null;
|
||||
let explicitStatusCodes: Array<number> | null = null;
|
||||
let explicitSecurity: Array<string> | null = null;
|
||||
let oauth2RequiredScopes: Array<string> | null = null;
|
||||
let oauth2ScopeMode: 'all' | 'any' | null = null;
|
||||
let oauth2BearerTokenRequired = false;
|
||||
let explicitTags: Array<string> | null = null;
|
||||
let explicitDeprecated = false;
|
||||
let explicitExternalDocs: {url: string; description?: string} | null = null;
|
||||
|
||||
for (let i = 1; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
|
||||
if (Node.isIdentifier(arg)) {
|
||||
const name = arg.getText();
|
||||
middlewares.push(name);
|
||||
|
||||
if (name === 'LoginRequired') {
|
||||
hasLoginRequired = true;
|
||||
} else if (name === 'DefaultUserOnly') {
|
||||
hasDefaultUserOnly = true;
|
||||
} else if (name === 'LoginRequiredAllowSuspicious') {
|
||||
hasLoginRequiredAllowSuspicious = true;
|
||||
} else if (name === 'SudoModeMiddleware') {
|
||||
hasSudoMode = true;
|
||||
}
|
||||
} else if (Node.isCallExpression(arg)) {
|
||||
const validatorInfo = extractValidatorInfo(arg);
|
||||
if (validatorInfo) {
|
||||
validators.push(validatorInfo);
|
||||
} else {
|
||||
const middlewareInfo = extractMiddlewareInfo(arg);
|
||||
if (middlewareInfo) {
|
||||
middlewares.push(middlewareInfo.middlewareName);
|
||||
if (middlewareInfo.rateLimitConfig) {
|
||||
rateLimitConfig = middlewareInfo.rateLimitConfig;
|
||||
}
|
||||
if (middlewareInfo.responseSchemaName) {
|
||||
responseSchemaName = middlewareInfo.responseSchemaName;
|
||||
}
|
||||
if (middlewareInfo.hasNoContent) {
|
||||
hasNoContent = true;
|
||||
}
|
||||
if (middlewareInfo.explicitSummary) {
|
||||
explicitSummary = middlewareInfo.explicitSummary;
|
||||
}
|
||||
if (middlewareInfo.explicitOperationId) {
|
||||
explicitOperationId = middlewareInfo.explicitOperationId;
|
||||
}
|
||||
if (middlewareInfo.explicitDescription) {
|
||||
explicitDescription = middlewareInfo.explicitDescription;
|
||||
}
|
||||
if (middlewareInfo.explicitStatusCodes) {
|
||||
explicitStatusCodes = middlewareInfo.explicitStatusCodes;
|
||||
}
|
||||
if (middlewareInfo.explicitSecurity) {
|
||||
explicitSecurity = middlewareInfo.explicitSecurity;
|
||||
}
|
||||
if (middlewareInfo.oauth2RequiredScopes && middlewareInfo.oauth2ScopeMode) {
|
||||
if (oauth2ScopeMode && oauth2ScopeMode !== middlewareInfo.oauth2ScopeMode) {
|
||||
throw new Error(
|
||||
`Cannot combine OAuth2 scope middleware modes on ${method.toUpperCase()} ${path} in ${sourceFile.getFilePath()}:${callExpr.getStartLineNumber()}`,
|
||||
);
|
||||
}
|
||||
oauth2ScopeMode = middlewareInfo.oauth2ScopeMode;
|
||||
const combinedScopes: Array<string> = [
|
||||
...(oauth2RequiredScopes ?? []),
|
||||
...middlewareInfo.oauth2RequiredScopes,
|
||||
];
|
||||
oauth2RequiredScopes = Array.from(new Set<string>(combinedScopes));
|
||||
}
|
||||
if (middlewareInfo.oauth2BearerTokenRequired) {
|
||||
oauth2BearerTokenRequired = true;
|
||||
}
|
||||
if (middlewareInfo.explicitTags) {
|
||||
explicitTags = middlewareInfo.explicitTags;
|
||||
}
|
||||
if (middlewareInfo.explicitDeprecated) {
|
||||
explicitDeprecated = middlewareInfo.explicitDeprecated;
|
||||
}
|
||||
if (middlewareInfo.explicitExternalDocs) {
|
||||
explicitExternalDocs = middlewareInfo.explicitExternalDocs;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (Node.isArrowFunction(arg) || Node.isFunctionExpression(arg)) {
|
||||
const handlerInfo = extractHandlerInfo(arg);
|
||||
if (handlerInfo) {
|
||||
handlerSource = handlerInfo.handlerSource;
|
||||
responseMapperName = handlerInfo.responseMapperName;
|
||||
successStatusCodes = handlerInfo.successStatusCodes;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
method,
|
||||
path,
|
||||
controllerFile: sourceFile.getFilePath(),
|
||||
lineNumber: callExpr.getStartLineNumber(),
|
||||
validators,
|
||||
middlewares,
|
||||
hasLoginRequired,
|
||||
hasDefaultUserOnly,
|
||||
hasLoginRequiredAllowSuspicious,
|
||||
hasSudoMode,
|
||||
rateLimitConfig,
|
||||
handlerSource,
|
||||
responseMapperName,
|
||||
responseSchemaName,
|
||||
hasNoContent,
|
||||
successStatusCodes,
|
||||
explicitSummary,
|
||||
explicitOperationId,
|
||||
explicitDescription,
|
||||
explicitStatusCodes,
|
||||
explicitSecurity,
|
||||
oauth2RequiredScopes,
|
||||
oauth2ScopeMode,
|
||||
oauth2BearerTokenRequired,
|
||||
explicitTags,
|
||||
explicitDeprecated,
|
||||
explicitExternalDocs,
|
||||
};
|
||||
}
|
||||
|
||||
function findRoutesInSourceFile(sourceFile: SourceFile): Array<ExtractedRoute> {
|
||||
const routes: Array<ExtractedRoute> = [];
|
||||
|
||||
sourceFile.forEachDescendant((node) => {
|
||||
if (Node.isCallExpression(node)) {
|
||||
const route = extractRouteFromCall(node, sourceFile);
|
||||
if (route) {
|
||||
routes.push(route);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
export function extractRoutesFromControllers(controllerPaths: Array<string>): Array<ExtractedRoute> {
|
||||
const project = new Project({
|
||||
skipAddingFilesFromTsConfig: true,
|
||||
skipFileDependencyResolution: true,
|
||||
});
|
||||
|
||||
const routes: Array<ExtractedRoute> = [];
|
||||
|
||||
for (const controllerPath of controllerPaths) {
|
||||
try {
|
||||
const sourceFile = project.addSourceFileAtPath(controllerPath);
|
||||
const fileRoutes = findRoutesInSourceFile(sourceFile);
|
||||
routes.push(...fileRoutes);
|
||||
} catch (error) {
|
||||
console.warn(`Warning: Could not parse ${controllerPath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
export function discoverControllerFiles(apiPackagePath: string): Array<string> {
|
||||
const project = new Project({
|
||||
tsConfigFilePath: `${apiPackagePath}/tsconfig.json`,
|
||||
skipAddingFilesFromTsConfig: true,
|
||||
});
|
||||
|
||||
const sourceFiles = project.addSourceFilesAtPaths([`${apiPackagePath}/src/**/*Controller.tsx`]);
|
||||
|
||||
return sourceFiles.map((sf) => sf.getFilePath());
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
196
packages/openapi/src/output/SpecValidator.tsx
Normal file
196
packages/openapi/src/output/SpecValidator.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
/*
|
||||
* 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 {OpenAPIDocument} from '@fluxer/openapi/src/Types';
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
errors: Array<ValidationError>;
|
||||
warnings: Array<ValidationWarning>;
|
||||
}
|
||||
|
||||
export interface ValidationError {
|
||||
path: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ValidationWarning {
|
||||
path: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function validateSpec(spec: OpenAPIDocument): ValidationResult {
|
||||
const errors: Array<ValidationError> = [];
|
||||
const warnings: Array<ValidationWarning> = [];
|
||||
|
||||
if (spec.openapi !== '3.1.0') {
|
||||
errors.push({path: 'openapi', message: `Expected "3.1.0", got "${spec.openapi}"`});
|
||||
}
|
||||
|
||||
if (!spec.info?.title) {
|
||||
errors.push({path: 'info.title', message: 'Missing required field'});
|
||||
}
|
||||
|
||||
if (!spec.info?.version) {
|
||||
errors.push({path: 'info.version', message: 'Missing required field'});
|
||||
}
|
||||
|
||||
if (!spec.paths || Object.keys(spec.paths).length === 0) {
|
||||
warnings.push({path: 'paths', message: 'No paths defined'});
|
||||
}
|
||||
|
||||
validateRefs(spec, errors);
|
||||
|
||||
validateOperationIds(spec, errors);
|
||||
|
||||
validatePaths(spec, errors, warnings);
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
function validateRefs(spec: OpenAPIDocument, errors: Array<ValidationError>): void {
|
||||
const definedSchemas = new Set(Object.keys(spec.components?.schemas ?? {}));
|
||||
|
||||
const checkRefs = (obj: unknown, path: string): void => {
|
||||
if (obj === null || typeof obj !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
obj.forEach((item, index) => checkRefs(item, `${path}[${index}]`));
|
||||
return;
|
||||
}
|
||||
|
||||
const record = obj as Record<string, unknown>;
|
||||
if ('$ref' in record && typeof record.$ref === 'string') {
|
||||
const ref = record.$ref;
|
||||
if (ref.startsWith('#/components/schemas/')) {
|
||||
const schemaName = ref.replace('#/components/schemas/', '');
|
||||
if (!definedSchemas.has(schemaName)) {
|
||||
errors.push({path, message: `Reference to undefined schema: ${schemaName}`});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
checkRefs(value, `${path}.${key}`);
|
||||
}
|
||||
};
|
||||
|
||||
checkRefs(spec.paths, 'paths');
|
||||
}
|
||||
|
||||
function validateOperationIds(spec: OpenAPIDocument, errors: Array<ValidationError>): void {
|
||||
const operationIds = new Set<string>();
|
||||
|
||||
for (const [pathKey, pathItem] of Object.entries(spec.paths)) {
|
||||
for (const [method, operation] of Object.entries(pathItem)) {
|
||||
if (typeof operation === 'object' && operation !== null && 'operationId' in operation) {
|
||||
const op = operation as {operationId?: string};
|
||||
if (op.operationId) {
|
||||
if (operationIds.has(op.operationId)) {
|
||||
errors.push({
|
||||
path: `paths.${pathKey}.${method}.operationId`,
|
||||
message: `Duplicate operationId: ${op.operationId}`,
|
||||
});
|
||||
} else {
|
||||
operationIds.add(op.operationId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validatePaths(
|
||||
spec: OpenAPIDocument,
|
||||
errors: Array<ValidationError>,
|
||||
warnings: Array<ValidationWarning>,
|
||||
): void {
|
||||
for (const [pathKey, pathItem] of Object.entries(spec.paths)) {
|
||||
if (!pathKey.startsWith('/')) {
|
||||
errors.push({path: `paths.${pathKey}`, message: 'Path must start with /'});
|
||||
}
|
||||
|
||||
const pathParams = pathKey.match(/\{(\w+)\}/g)?.map((p) => p.slice(1, -1)) ?? [];
|
||||
|
||||
for (const [method, operation] of Object.entries(pathItem)) {
|
||||
if (typeof operation !== 'object' || operation === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const op = operation as {
|
||||
operationId?: string;
|
||||
responses?: Record<string, unknown>;
|
||||
parameters?: Array<{name: string; in: string}>;
|
||||
};
|
||||
|
||||
if (!op.operationId) {
|
||||
warnings.push({
|
||||
path: `paths.${pathKey}.${method}`,
|
||||
message: 'Missing operationId',
|
||||
});
|
||||
}
|
||||
|
||||
if (!op.responses || Object.keys(op.responses).length === 0) {
|
||||
errors.push({
|
||||
path: `paths.${pathKey}.${method}.responses`,
|
||||
message: 'At least one response is required',
|
||||
});
|
||||
}
|
||||
|
||||
const definedParams = new Set(op.parameters?.filter((p) => p.in === 'path').map((p) => p.name) ?? []);
|
||||
|
||||
for (const param of pathParams) {
|
||||
if (!definedParams.has(param)) {
|
||||
warnings.push({
|
||||
path: `paths.${pathKey}.${method}`,
|
||||
message: `Path parameter "${param}" not defined in parameters`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function printValidationResult(result: ValidationResult): void {
|
||||
if (result.valid) {
|
||||
console.log('Validation passed');
|
||||
} else {
|
||||
console.log('Validation failed');
|
||||
}
|
||||
|
||||
if (result.errors.length > 0) {
|
||||
console.log('\nErrors:');
|
||||
for (const error of result.errors) {
|
||||
console.log(` - [${error.path}] ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.warnings.length > 0) {
|
||||
console.log('\nWarnings:');
|
||||
for (const warning of result.warnings) {
|
||||
console.log(` - [${warning.path}] ${warning.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
47
packages/openapi/src/output/SpecWriter.tsx
Normal file
47
packages/openapi/src/output/SpecWriter.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* 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 * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import type {OpenAPIDocument} from '@fluxer/openapi/src/Types';
|
||||
|
||||
export function writeSpec(spec: OpenAPIDocument, outputPath: string): void {
|
||||
const dir = path.dirname(outputPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, {recursive: true});
|
||||
}
|
||||
|
||||
const json = JSON.stringify(spec, null, 2);
|
||||
const tempPath = `${outputPath}.tmp`;
|
||||
fs.writeFileSync(tempPath, `${json}\n`, 'utf-8');
|
||||
fs.renameSync(tempPath, outputPath);
|
||||
}
|
||||
|
||||
export function readSpec(inputPath: string): OpenAPIDocument {
|
||||
const content = fs.readFileSync(inputPath, 'utf-8');
|
||||
return JSON.parse(content) as OpenAPIDocument;
|
||||
}
|
||||
|
||||
export function formatSpec(spec: OpenAPIDocument): string {
|
||||
return JSON.stringify(spec, null, 2);
|
||||
}
|
||||
|
||||
export function getDefaultOutputPath(basePath: string): string {
|
||||
return path.join(basePath, 'fluxer_docs', 'api-reference', 'openapi.json');
|
||||
}
|
||||
168
packages/openapi/src/registry/ParameterRegistry.tsx
Normal file
168
packages/openapi/src/registry/ParameterRegistry.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
/*
|
||||
* 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 type {OpenAPIParameter} from '@fluxer/openapi/src/Types';
|
||||
|
||||
export const COMMON_PATH_PARAMETERS: Record<string, OpenAPIParameter> = {
|
||||
guild_id: {
|
||||
name: 'guild_id',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: SnowflakeTypeRef,
|
||||
description: 'The ID of the guild',
|
||||
},
|
||||
channel_id: {
|
||||
name: 'channel_id',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: SnowflakeTypeRef,
|
||||
description: 'The ID of the channel',
|
||||
},
|
||||
user_id: {
|
||||
name: 'user_id',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: SnowflakeTypeRef,
|
||||
description: 'The ID of the user',
|
||||
},
|
||||
message_id: {
|
||||
name: 'message_id',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: SnowflakeTypeRef,
|
||||
description: 'The ID of the message',
|
||||
},
|
||||
role_id: {
|
||||
name: 'role_id',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: SnowflakeTypeRef,
|
||||
description: 'The ID of the role',
|
||||
},
|
||||
emoji_id: {
|
||||
name: 'emoji_id',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: SnowflakeTypeRef,
|
||||
description: 'The ID of the emoji',
|
||||
},
|
||||
sticker_id: {
|
||||
name: 'sticker_id',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: SnowflakeTypeRef,
|
||||
description: 'The ID of the sticker',
|
||||
},
|
||||
invite_code: {
|
||||
name: 'invite_code',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: {type: 'string'},
|
||||
description: 'The invite code',
|
||||
},
|
||||
webhook_id: {
|
||||
name: 'webhook_id',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: SnowflakeTypeRef,
|
||||
description: 'The ID of the webhook',
|
||||
},
|
||||
webhook_token: {
|
||||
name: 'webhook_token',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: {type: 'string'},
|
||||
description: 'The webhook token',
|
||||
},
|
||||
pack_id: {
|
||||
name: 'pack_id',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: SnowflakeTypeRef,
|
||||
description: 'The ID of the pack',
|
||||
},
|
||||
application_id: {
|
||||
name: 'application_id',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: SnowflakeTypeRef,
|
||||
description: 'The ID of the OAuth2 application',
|
||||
},
|
||||
};
|
||||
|
||||
export const COMMON_QUERY_PARAMETERS: Record<string, OpenAPIParameter> = {
|
||||
limit: {
|
||||
name: 'limit',
|
||||
in: 'query',
|
||||
required: false,
|
||||
schema: {type: 'integer', minimum: 1, maximum: 100, default: 50},
|
||||
description: 'Maximum number of results to return',
|
||||
},
|
||||
before: {
|
||||
name: 'before',
|
||||
in: 'query',
|
||||
required: false,
|
||||
schema: SnowflakeTypeRef,
|
||||
description: 'Get results before this ID',
|
||||
},
|
||||
after: {
|
||||
name: 'after',
|
||||
in: 'query',
|
||||
required: false,
|
||||
schema: SnowflakeTypeRef,
|
||||
description: 'Get results after this ID',
|
||||
},
|
||||
around: {
|
||||
name: 'around',
|
||||
in: 'query',
|
||||
required: false,
|
||||
schema: SnowflakeTypeRef,
|
||||
description: 'Get results around this ID',
|
||||
},
|
||||
};
|
||||
|
||||
export function extractPathParameters(path: string): Array<OpenAPIParameter> {
|
||||
const paramRegex = /:(\w+)/g;
|
||||
const parameters: Array<OpenAPIParameter> = [];
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = paramRegex.exec(path)) !== null) {
|
||||
const paramName = match[1];
|
||||
const commonParam = COMMON_PATH_PARAMETERS[paramName];
|
||||
|
||||
if (commonParam) {
|
||||
parameters.push({...commonParam});
|
||||
} else {
|
||||
parameters.push({
|
||||
name: paramName,
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: {type: 'string'},
|
||||
description: `The ${paramName.replace(/_/g, ' ')}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return parameters;
|
||||
}
|
||||
|
||||
export function convertPathToOpenAPI(path: string): string {
|
||||
return path.replace(/:(\w+)/g, '{$1}');
|
||||
}
|
||||
164
packages/openapi/src/registry/ResponseRegistry.tsx
Normal file
164
packages/openapi/src/registry/ResponseRegistry.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
/*
|
||||
* 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 {OpenAPIResponse, OpenAPISchema} from '@fluxer/openapi/src/Types';
|
||||
|
||||
export const ERROR_SCHEMA: OpenAPISchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
code: {
|
||||
$ref: '#/components/schemas/APIErrorCode',
|
||||
},
|
||||
message: {
|
||||
type: 'string',
|
||||
description: 'Human-readable error message',
|
||||
},
|
||||
errors: {
|
||||
type: 'array',
|
||||
description: 'Field-specific validation errors',
|
||||
items: {
|
||||
$ref: '#/components/schemas/ValidationErrorItem',
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['code', 'message'],
|
||||
};
|
||||
|
||||
export const RATE_LIMIT_HEADERS: Record<string, {description: string; schema: OpenAPISchema}> = {
|
||||
'X-RateLimit-Limit': {
|
||||
description: 'The number of requests that can be made in the current window',
|
||||
schema: {type: 'integer'},
|
||||
},
|
||||
'X-RateLimit-Remaining': {
|
||||
description: 'The number of remaining requests that can be made',
|
||||
schema: {type: 'integer'},
|
||||
},
|
||||
'X-RateLimit-Reset': {
|
||||
description: 'Unix timestamp when the rate limit resets',
|
||||
schema: {type: 'integer'},
|
||||
},
|
||||
'Retry-After': {
|
||||
description: 'Number of seconds to wait before retrying (only on 429)',
|
||||
schema: {type: 'integer'},
|
||||
},
|
||||
};
|
||||
|
||||
export const COMMON_RESPONSES: Record<string, OpenAPIResponse> = {
|
||||
'400': {
|
||||
description: 'Bad Request - The request was malformed or contained invalid data',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {$ref: '#/components/schemas/Error'},
|
||||
},
|
||||
},
|
||||
},
|
||||
'401': {
|
||||
description: 'Unauthorized - Authentication is required or the token is invalid',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {$ref: '#/components/schemas/Error'},
|
||||
},
|
||||
},
|
||||
},
|
||||
'403': {
|
||||
description: 'Forbidden - You do not have permission to perform this action',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {$ref: '#/components/schemas/Error'},
|
||||
},
|
||||
},
|
||||
},
|
||||
'404': {
|
||||
description: 'Not Found - The requested resource was not found',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {$ref: '#/components/schemas/Error'},
|
||||
},
|
||||
},
|
||||
},
|
||||
'429': {
|
||||
description: 'Too Many Requests - You are being rate limited',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
code: {type: 'string', enum: ['RATE_LIMITED']},
|
||||
message: {type: 'string'},
|
||||
retry_after: {type: 'number', description: 'Seconds to wait before retrying'},
|
||||
global: {type: 'boolean', description: 'Whether this is a global rate limit'},
|
||||
},
|
||||
required: ['code', 'message', 'retry_after'],
|
||||
},
|
||||
},
|
||||
},
|
||||
headers: {
|
||||
'Retry-After': RATE_LIMIT_HEADERS['Retry-After'],
|
||||
'X-RateLimit-Limit': RATE_LIMIT_HEADERS['X-RateLimit-Limit'],
|
||||
'X-RateLimit-Remaining': RATE_LIMIT_HEADERS['X-RateLimit-Remaining'],
|
||||
'X-RateLimit-Reset': RATE_LIMIT_HEADERS['X-RateLimit-Reset'],
|
||||
},
|
||||
},
|
||||
'500': {
|
||||
description: 'Internal Server Error - An unexpected error occurred',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {$ref: '#/components/schemas/Error'},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function getErrorResponses(requiresAuth: boolean): Record<string, OpenAPIResponse> {
|
||||
const responses: Record<string, OpenAPIResponse> = {
|
||||
'400': COMMON_RESPONSES['400'],
|
||||
'429': COMMON_RESPONSES['429'],
|
||||
'500': COMMON_RESPONSES['500'],
|
||||
};
|
||||
|
||||
if (requiresAuth) {
|
||||
responses['401'] = COMMON_RESPONSES['401'];
|
||||
responses['403'] = COMMON_RESPONSES['403'];
|
||||
}
|
||||
|
||||
return responses;
|
||||
}
|
||||
|
||||
export function getSuccessResponse(schemaRef: string | null): OpenAPIResponse {
|
||||
if (!schemaRef) {
|
||||
return {
|
||||
description: 'Success',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
description: 'Success',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {$ref: schemaRef},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getNoContentResponse(): OpenAPIResponse {
|
||||
return {
|
||||
description: 'No Content',
|
||||
};
|
||||
}
|
||||
137
packages/openapi/src/registry/SchemaLoader.tsx
Normal file
137
packages/openapi/src/registry/SchemaLoader.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* 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 * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import {setSchemaName, zodToOpenAPISchema} from '@fluxer/openapi/src/converters/ZodToOpenAPI';
|
||||
import type {OpenAPISchema} from '@fluxer/openapi/src/Types';
|
||||
import type {z} from 'zod';
|
||||
|
||||
export interface LoadedSchema {
|
||||
name: string;
|
||||
zodSchema: z.ZodTypeAny;
|
||||
openAPISchema: OpenAPISchema;
|
||||
}
|
||||
|
||||
function discoverSchemaModules(rootDir: string): Array<string> {
|
||||
if (!fs.existsSync(rootDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const results: Array<string> = [];
|
||||
const stack: Array<string> = [rootDir];
|
||||
|
||||
while (stack.length > 0) {
|
||||
const currentDir = stack.pop();
|
||||
if (!currentDir) break;
|
||||
|
||||
const entries = fs.readdirSync(currentDir, {withFileTypes: true});
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(currentDir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === 'tests' || entry.name === 'node_modules') continue;
|
||||
stack.push(fullPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!entry.isFile()) continue;
|
||||
if (!entry.name.endsWith('.tsx')) continue;
|
||||
if (entry.name.endsWith('.test.tsx')) continue;
|
||||
|
||||
results.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return results.sort();
|
||||
}
|
||||
|
||||
function getModulePaths(basePath: string): Array<string> {
|
||||
const schemaDomains = path.join(basePath, 'packages', 'schema', 'src', 'domains');
|
||||
return discoverSchemaModules(schemaDomains);
|
||||
}
|
||||
|
||||
function isZodSchema(value: unknown): value is z.ZodTypeAny {
|
||||
return (
|
||||
value !== null &&
|
||||
typeof value === 'object' &&
|
||||
'_def' in value &&
|
||||
typeof (value as {parse?: unknown}).parse === 'function'
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadSchemas(basePath: string): Promise<Map<string, LoadedSchema>> {
|
||||
const schemas = new Map<string, LoadedSchema>();
|
||||
const collectedSchemas: Array<{name: string; zodSchema: z.ZodTypeAny}> = [];
|
||||
|
||||
const modulePaths = getModulePaths(basePath);
|
||||
|
||||
for (const modulePath of modulePaths) {
|
||||
try {
|
||||
const moduleExports = await import(modulePath);
|
||||
|
||||
for (const [exportName, exportValue] of Object.entries(moduleExports)) {
|
||||
if (exportName.startsWith('_')) {
|
||||
continue;
|
||||
}
|
||||
if (typeof exportValue === 'function') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isZodSchema(exportValue)) {
|
||||
collectedSchemas.push({name: exportName, zodSchema: exportValue});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Warning: Could not load schema module ${modulePath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
for (const {name, zodSchema} of collectedSchemas) {
|
||||
setSchemaName(zodSchema, name);
|
||||
}
|
||||
|
||||
for (const {name, zodSchema} of collectedSchemas) {
|
||||
const openAPISchemaOrRef = zodToOpenAPISchema(zodSchema);
|
||||
if ('$ref' in openAPISchemaOrRef) {
|
||||
throw new Error(`Top-level schema export must not be a $ref: ${name}`);
|
||||
}
|
||||
const openAPISchema: OpenAPISchema = openAPISchemaOrRef;
|
||||
schemas.set(name, {
|
||||
name,
|
||||
zodSchema,
|
||||
openAPISchema,
|
||||
});
|
||||
}
|
||||
|
||||
return schemas;
|
||||
}
|
||||
|
||||
export function getSchemaNames(basePath: string): Array<string> {
|
||||
const modulePaths = getModulePaths(basePath);
|
||||
const names: Array<string> = [];
|
||||
|
||||
for (const modulePath of modulePaths) {
|
||||
const parts = modulePath.split('/');
|
||||
const fileName = parts[parts.length - 1];
|
||||
names.push(fileName);
|
||||
}
|
||||
|
||||
return names;
|
||||
}
|
||||
78
packages/openapi/src/registry/SchemaRegistry.tsx
Normal file
78
packages/openapi/src/registry/SchemaRegistry.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* 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 {OpenAPIRef, OpenAPISchema} from '@fluxer/openapi/src/Types';
|
||||
|
||||
export class SchemaRegistry {
|
||||
private schemas: Map<string, OpenAPISchema> = new Map();
|
||||
private references: Set<string> = new Set();
|
||||
|
||||
register(name: string, schema: OpenAPISchema): void {
|
||||
this.schemas.set(name, schema);
|
||||
}
|
||||
|
||||
getRef(name: string): OpenAPIRef {
|
||||
this.references.add(name);
|
||||
return {$ref: `#/components/schemas/${name}`};
|
||||
}
|
||||
|
||||
has(name: string): boolean {
|
||||
return this.schemas.has(name);
|
||||
}
|
||||
|
||||
get(name: string): OpenAPISchema | undefined {
|
||||
return this.schemas.get(name);
|
||||
}
|
||||
|
||||
getAllSchemas(): Record<string, OpenAPISchema> {
|
||||
const result: Record<string, OpenAPISchema> = {};
|
||||
for (const [name, schema] of this.schemas) {
|
||||
result[name] = schema;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
getReferencedSchemas(): Record<string, OpenAPISchema> {
|
||||
const result: Record<string, OpenAPISchema> = {};
|
||||
for (const name of this.references) {
|
||||
const schema = this.schemas.get(name);
|
||||
if (schema) {
|
||||
result[name] = schema;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
getUnreferencedSchemas(): Array<string> {
|
||||
const unreferenced: Array<string> = [];
|
||||
for (const name of this.schemas.keys()) {
|
||||
if (!this.references.has(name)) {
|
||||
unreferenced.push(name);
|
||||
}
|
||||
}
|
||||
return unreferenced;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.schemas.clear();
|
||||
this.references.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export const globalSchemaRegistry = new SchemaRegistry();
|
||||
79
packages/openapi/src/schemas/CustomSchemaType.tsx
Normal file
79
packages/openapi/src/schemas/CustomSchemaType.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* 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 {OpenAPIRef, OpenAPISchema} from '@fluxer/openapi/src/Types';
|
||||
import type {ZodTypeAny} from 'zod';
|
||||
|
||||
const FLUXER_CUSTOM_TYPE_KEY = '__fluxer_custom_type__';
|
||||
|
||||
export interface CustomSchemaTypeConfig<TName extends string = string> {
|
||||
readonly name: TName;
|
||||
readonly zodSchema: ZodTypeAny;
|
||||
readonly openApiSchema: OpenAPISchema;
|
||||
}
|
||||
|
||||
const registry = new Map<string, CustomSchemaType>();
|
||||
|
||||
export class CustomSchemaType<TName extends string = string> {
|
||||
public readonly name: TName;
|
||||
public readonly zodSchema: ZodTypeAny;
|
||||
public readonly openApiSchema: OpenAPISchema;
|
||||
public readonly ref: OpenAPIRef;
|
||||
|
||||
constructor(config: CustomSchemaTypeConfig<TName>) {
|
||||
this.name = config.name;
|
||||
this.openApiSchema = config.openApiSchema;
|
||||
this.ref = {$ref: `#/components/schemas/${config.name}`};
|
||||
this.zodSchema = markAsCustomType(config.zodSchema, config.name);
|
||||
registry.set(config.name, this);
|
||||
}
|
||||
|
||||
public static get(name: string): CustomSchemaType | undefined {
|
||||
return registry.get(name);
|
||||
}
|
||||
|
||||
public static getRef(name: string): OpenAPIRef | null {
|
||||
const type = registry.get(name);
|
||||
return type?.ref ?? null;
|
||||
}
|
||||
|
||||
public static getAll(): ReadonlyMap<string, CustomSchemaType> {
|
||||
return registry;
|
||||
}
|
||||
|
||||
public static getAllSchemas(): Record<string, OpenAPISchema> {
|
||||
const result: Record<string, OpenAPISchema> = {};
|
||||
for (const [name, type] of registry) {
|
||||
result[name] = type.openApiSchema;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export function markAsCustomType<T extends ZodTypeAny>(schema: T, typeName: string): T {
|
||||
(schema as Record<string, unknown>)[FLUXER_CUSTOM_TYPE_KEY] = typeName;
|
||||
return schema;
|
||||
}
|
||||
|
||||
export function defineCustomType<TName extends string>(
|
||||
config: CustomSchemaTypeConfig<TName>,
|
||||
): CustomSchemaType<TName>['zodSchema'] {
|
||||
const type = new CustomSchemaType(config);
|
||||
return type.zodSchema;
|
||||
}
|
||||
142
packages/openapi/src/scripts/GenerateSpec.tsx
Normal file
142
packages/openapi/src/scripts/GenerateSpec.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env tsx
|
||||
|
||||
/*
|
||||
* 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 {execFileSync} from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import {OpenAPIGenerator} from '@fluxer/openapi/src/OpenAPIGenerator';
|
||||
import {printValidationResult, validateSpec} from '@fluxer/openapi/src/output/SpecValidator';
|
||||
import {getDefaultOutputPath, readSpec, writeSpec} from '@fluxer/openapi/src/output/SpecWriter';
|
||||
|
||||
function parseArgs(): {validateOnly: boolean; outputPath: string | null} {
|
||||
const args = process.argv.slice(2);
|
||||
let validateOnly = false;
|
||||
let outputPath: string | null = null;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (arg === '--validate-only' || arg === '-v') {
|
||||
validateOnly = true;
|
||||
} else if (arg === '--output' || arg === '-o') {
|
||||
outputPath = args[++i];
|
||||
}
|
||||
}
|
||||
|
||||
return {validateOnly, outputPath};
|
||||
}
|
||||
|
||||
function findRepositoryRoot(): string {
|
||||
let dir = process.cwd();
|
||||
while (dir !== '/') {
|
||||
const workspacePath = path.join(dir, 'pnpm-workspace.yaml');
|
||||
if (fs.existsSync(workspacePath)) {
|
||||
return dir;
|
||||
}
|
||||
dir = path.dirname(dir);
|
||||
}
|
||||
throw new Error('Could not find repository root (no pnpm-workspace.yaml found)');
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const {validateOnly, outputPath: customOutputPath} = parseArgs();
|
||||
|
||||
const basePath = findRepositoryRoot();
|
||||
const outputPath = customOutputPath ?? getDefaultOutputPath(basePath);
|
||||
|
||||
console.log('Fluxer OpenAPI Specification Generator');
|
||||
console.log('======================================');
|
||||
console.log(`Base path: ${basePath}`);
|
||||
console.log(`Output path: ${outputPath}`);
|
||||
console.log('');
|
||||
|
||||
if (validateOnly) {
|
||||
console.log('Running validation only...');
|
||||
try {
|
||||
const spec = readSpec(outputPath);
|
||||
const result = validateSpec(spec);
|
||||
printValidationResult(result);
|
||||
process.exit(result.valid ? 0 : 1);
|
||||
} catch (error) {
|
||||
console.error('Failed to read spec file:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const generator = new OpenAPIGenerator({
|
||||
basePath,
|
||||
title: 'Fluxer API',
|
||||
version: '1.0.0',
|
||||
description:
|
||||
'API for Fluxer, a free and open source instant messaging and VoIP platform built for friends, groups, and communities.',
|
||||
serverUrl: 'https://api.fluxer.app/v1',
|
||||
});
|
||||
|
||||
const spec = await generator.generate();
|
||||
|
||||
console.log('');
|
||||
console.log('Validating specification...');
|
||||
const validationResult = validateSpec(spec);
|
||||
printValidationResult(validationResult);
|
||||
|
||||
if (!validationResult.valid) {
|
||||
console.error('');
|
||||
console.error('Specification has validation errors. Continuing anyway...');
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(`Writing specification to ${outputPath}...`);
|
||||
writeSpec(spec, outputPath);
|
||||
|
||||
console.log('');
|
||||
console.log('Generating resource overview page...');
|
||||
runResourceOverviewGenerator(basePath);
|
||||
|
||||
const pathCount = Object.keys(spec.paths).length;
|
||||
let operationCount = 0;
|
||||
for (const pathItem of Object.values(spec.paths)) {
|
||||
operationCount += Object.keys(pathItem).length;
|
||||
}
|
||||
const schemaCount = Object.keys(spec.components.schemas).length;
|
||||
|
||||
console.log('');
|
||||
console.log('Summary');
|
||||
console.log('-------');
|
||||
console.log(`Paths: ${pathCount}`);
|
||||
console.log(`Operations: ${operationCount}`);
|
||||
console.log(`Schemas: ${schemaCount}`);
|
||||
console.log('');
|
||||
console.log('OpenAPI specification generated successfully.');
|
||||
} catch (error) {
|
||||
console.error('Failed to generate specification:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function runResourceOverviewGenerator(basePath: string): void {
|
||||
const scriptPath = path.join(basePath, 'fluxer_docs/scripts/generate_resources.mjs');
|
||||
execFileSync('node', [scriptPath], {stdio: 'inherit'});
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Unhandled error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user