refactor progress

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

View File

@@ -0,0 +1,24 @@
{
"name": "@fluxer/openapi",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
"./*": "./*"
},
"scripts": {
"generate": "tsx src/scripts/GenerateSpec.tsx",
"typecheck": "tsgo --noEmit",
"validate": "tsx src/scripts/GenerateSpec.tsx --validate-only"
},
"dependencies": {
"@fluxer/constants": "workspace:*",
"ts-morph": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"tsx": "catalog:"
}
}

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

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

View 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],
}));
}
}

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

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

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

File diff suppressed because it is too large Load Diff

View 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(' ');
}

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

View File

@@ -0,0 +1,201 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {
APIErrorCodeSchema,
Base64ImageTypeSchema,
EmailTypeSchema,
Int32TypeSchema,
Int64StringTypeSchema,
Int64TypeSchema,
LocaleSchema,
PasswordTypeSchema,
PhoneNumberTypeSchema,
SnowflakeTypeSchema,
UnsignedInt64TypeSchema,
UsernameTypeSchema,
} from '@fluxer/openapi/src/converters/BuiltInSchemas';
import type {OpenAPISchema, OpenAPISecurityScheme} from '@fluxer/openapi/src/OpenAPITypes';
import {ERROR_SCHEMA} from '@fluxer/openapi/src/registry/ResponseRegistry';
const ORDERED_TAG_NAMES = [
'Auth',
'Users',
'Guilds',
'Channels',
'Invites',
'Packs',
'Webhooks',
'OAuth2',
'Gateway',
'Search',
'Read States',
'KLIPY',
'Saved Media',
'Themes',
'Downloads',
'Reports',
'Instance',
'Admin',
'Billing',
'Premium',
'Gifts',
'RPC',
] as const;
interface TagDefinition {
readonly name: (typeof ORDERED_TAG_NAMES)[number];
readonly description: string;
}
const TAG_DEFINITIONS: ReadonlyArray<TagDefinition> = [
{name: 'Auth', description: 'Authentication and session management'},
{name: 'Users', description: 'User accounts and profiles'},
{name: 'Guilds', description: 'Guild management'},
{name: 'Channels', description: 'Channel management and messaging'},
{name: 'Invites', description: 'Guild invitations'},
{name: 'Packs', description: 'Sticker and emoji packs'},
{name: 'Webhooks', description: 'Webhook management'},
{name: 'OAuth2', description: 'OAuth2 applications and authorization'},
{name: 'Gateway', description: 'WebSocket gateway information'},
{name: 'Search', description: 'Search functionality'},
{name: 'Read States', description: 'Message read state tracking'},
{name: 'KLIPY', description: 'GIF search via KLIPY'},
{name: 'Saved Media', description: 'User saved media management'},
{name: 'Themes', description: 'User interface themes'},
{name: 'Downloads', description: 'App downloads'},
{name: 'Reports', description: 'Content reporting'},
{name: 'Instance', description: 'Instance configuration and info'},
{name: 'Admin', description: 'Administrative operations for instance management'},
{name: 'Billing', description: 'Subscription and payment management via Stripe'},
{name: 'Premium', description: 'Premium subscription features and benefits'},
{name: 'Gifts', description: 'Gift codes and redemption'},
{name: 'RPC', description: 'Remote procedure call endpoints for internal operations'},
] as const;
const TAG_DESCRIPTIONS = TAG_DEFINITIONS.reduce<Record<(typeof ORDERED_TAG_NAMES)[number], string>>(
(acc, definition) => {
acc[definition.name] = definition.description;
return acc;
},
{} as Record<(typeof ORDERED_TAG_NAMES)[number], string>,
);
const SECURITY_SCHEMES: Record<string, OpenAPISecurityScheme> = {
botToken: {
type: 'apiKey',
in: 'header',
name: 'Authorization',
description:
'Bot token: `Authorization: Bot <token>`. This is the primary authentication method for bot applications.',
},
oauth2Token: {
type: 'oauth2',
description: 'OAuth2 access token: `Authorization: Bearer <token>`.',
flows: {
authorizationCode: {
authorizationUrl: '/oauth2/authorize',
tokenUrl: '/oauth2/token',
scopes: {
identify: 'Read basic user identity information.',
email: 'Read the user email address.',
guilds: 'Read guild membership information for the current user.',
connections: 'Read linked third-party account connections for the current user.',
bot: 'Add a bot user to a guild.',
admin: 'Access admin endpoints when the user has admin ACLs.',
},
},
},
},
bearerToken: {
type: 'http',
scheme: 'bearer',
description:
'Bearer-form token: `Authorization: Bearer <token>`. Use `oauth2Token` when a route requires OAuth2 scopes.',
},
sessionToken: {
type: 'apiKey',
in: 'header',
name: 'Authorization',
description:
'User session token from login: `Authorization: <token>` (no prefix). Prefer a bot account over user tokens where possible.',
},
adminApiKey: {
type: 'apiKey',
in: 'header',
name: 'Authorization',
description: 'Admin API key: `Authorization: Admin <token>`. Only valid for `/admin/*` endpoints.',
},
};
const BUILT_IN_SCHEMAS: ReadonlyArray<readonly [string, OpenAPISchema]> = [
['Error', ERROR_SCHEMA],
['APIErrorCode', APIErrorCodeSchema],
['SnowflakeType', SnowflakeTypeSchema],
['Int32Type', Int32TypeSchema],
['Int64Type', Int64TypeSchema],
['Int64StringType', Int64StringTypeSchema],
['UnsignedInt64Type', UnsignedInt64TypeSchema],
['UsernameType', UsernameTypeSchema],
['EmailType', EmailTypeSchema],
['PasswordType', PasswordTypeSchema],
['PhoneNumberType', PhoneNumberTypeSchema],
['Base64ImageType', Base64ImageTypeSchema],
['Locale', LocaleSchema],
];
export interface IOpenAPIGeneratorCatalog {
readonly excluded: {
readonly prefixes: ReadonlyArray<string>;
readonly paths: ReadonlySet<string>;
};
readonly tags: {
readonly order: ReadonlyArray<string>;
readonly descriptions: Record<string, string>;
};
readonly securitySchemes: Record<string, OpenAPISecurityScheme>;
readonly builtInSchemas: ReadonlyArray<readonly [string, OpenAPISchema]>;
}
export const OpenAPIGeneratorCatalog: IOpenAPIGeneratorCatalog = {
excluded: {
prefixes: ['/test/'],
paths: new Set<string>(['/_rpc', '/oauth2/authorize']),
},
tags: {
order: ORDERED_TAG_NAMES,
descriptions: TAG_DESCRIPTIONS,
},
securitySchemes: SECURITY_SCHEMES,
builtInSchemas: BUILT_IN_SCHEMAS,
};
export function isExcludedRoutePath(routePath: string): boolean {
if (OpenAPIGeneratorCatalog.excluded.paths.has(routePath)) {
return true;
}
for (const prefix of OpenAPIGeneratorCatalog.excluded.prefixes) {
if (routePath.startsWith(prefix) || routePath === prefix.slice(0, -1)) {
return true;
}
}
return false;
}

View File

@@ -0,0 +1,723 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {SnowflakeTypeRef} from '@fluxer/openapi/src/converters/BuiltInSchemas';
import {analyzeSecurityRequirements} from '@fluxer/openapi/src/extractors/MiddlewareAnalyzer';
import type {
ExtractedRoute,
ExtractedValidator,
OpenAPIOperation,
OpenAPIParameter,
OpenAPIRequestBody,
OpenAPIResponse,
OpenAPISchema,
OpenAPISchemaOrRef,
} from '@fluxer/openapi/src/OpenAPITypes';
import {extractPathParameters} from '@fluxer/openapi/src/registry/ParameterRegistry';
import {getErrorResponses, getNoContentResponse} from '@fluxer/openapi/src/registry/ResponseRegistry';
import type {LoadedSchema} from '@fluxer/openapi/src/registry/SchemaLoader';
import type {SchemaRegistry} from '@fluxer/openapi/src/registry/SchemaRegistry';
interface OpenAPIOperationBuilderDependencies {
readonly schemaRegistry: SchemaRegistry;
readonly loadedSchemas: Map<string, LoadedSchema>;
readonly usedOperationIds: Set<string>;
}
export class OpenAPIOperationBuilder {
private readonly schemaRegistry: SchemaRegistry;
private readonly loadedSchemas: Map<string, LoadedSchema>;
private readonly usedOperationIds: Set<string>;
constructor(dependencies: OpenAPIOperationBuilderDependencies) {
this.schemaRegistry = dependencies.schemaRegistry;
this.loadedSchemas = dependencies.loadedSchemas;
this.usedOperationIds = dependencies.usedOperationIds;
}
public buildOperation(route: ExtractedRoute): OpenAPIOperation {
if (!route.explicitTags || route.explicitTags.length === 0) {
throw new Error(
`Missing explicit tags for ${route.method.toUpperCase()} ${route.path} in ${route.controllerFile}:${route.lineNumber}. All endpoints must use the OpenAPI middleware with explicit tags.`,
);
}
if (!route.explicitSummary) {
throw new Error(
`Missing explicit summary for ${route.method.toUpperCase()} ${route.path} in ${route.controllerFile}:${route.lineNumber}. All endpoints must use the OpenAPI middleware with an explicit summary.`,
);
}
if (!route.explicitOperationId) {
throw new Error(
`Missing explicit operationId for ${route.method.toUpperCase()} ${route.path} in ${route.controllerFile}:${route.lineNumber}. All endpoints must use the OpenAPI middleware with an explicit operationId in snake_case.`,
);
}
const security = route.explicitSecurity
? this.buildSecurityFromExplicit(route.explicitSecurity, route)
: this.buildSecurity(route);
const parameters = this.buildParameters(route);
const requestBody = this.buildRequestBody(route);
const responses = this.buildResponses(route, route.explicitStatusCodes);
const operation: OpenAPIOperation = {
operationId: this.getUniqueOperationId(route.explicitOperationId),
summary: route.explicitSummary,
tags: route.explicitTags,
responses,
'x-mint': {metadata: {title: route.explicitSummary}},
};
if (route.explicitDescription) {
operation.description = route.explicitDescription;
}
if (route.explicitDeprecated) {
operation.deprecated = route.explicitDeprecated;
}
if (route.explicitExternalDocs) {
operation.externalDocs = route.explicitExternalDocs;
}
if (security.length > 0) {
operation.security = security;
}
if (parameters.length > 0) {
operation.parameters = parameters;
}
if (requestBody) {
operation.requestBody = requestBody;
}
return operation;
}
private getUniqueOperationId(baseId: string): string {
let operationId = baseId;
let counter = 2;
while (this.usedOperationIds.has(operationId)) {
operationId = `${baseId}${counter}`;
counter++;
}
this.usedOperationIds.add(operationId);
return operationId;
}
private buildSecurityFromExplicit(
explicitSecurity: Array<string>,
route: ExtractedRoute,
): Array<Record<string, Array<string>>> {
const baseSecurity = explicitSecurity.map((scheme) => ({[scheme]: []}));
return this.applyOAuth2ScopeSecurity(baseSecurity, route);
}
private buildSecurity(route: ExtractedRoute): Array<Record<string, Array<string>>> {
let baseSecurity: Array<Record<string, Array<string>>>;
if (route.path.startsWith('/admin/') || route.middlewares.includes('requireAdminACL')) {
baseSecurity = [{adminApiKey: []}];
return this.applyOAuth2ScopeSecurity(baseSecurity, route);
}
if (route.path === '/applications/@me') {
baseSecurity = [{botToken: []}];
return this.applyOAuth2ScopeSecurity(baseSecurity, route);
}
if (route.path === '/users/@me' || route.path.startsWith('/users/@me/')) {
baseSecurity = [{bearerToken: []}, {sessionToken: []}];
return this.applyOAuth2ScopeSecurity(baseSecurity, route);
}
const security = analyzeSecurityRequirements(route);
if (security.type !== 'bearer') {
return [];
}
if (route.hasDefaultUserOnly) {
baseSecurity = [{bearerToken: []}, {sessionToken: []}];
return this.applyOAuth2ScopeSecurity(baseSecurity, route);
}
baseSecurity = [{botToken: []}, {bearerToken: []}, {sessionToken: []}];
return this.applyOAuth2ScopeSecurity(baseSecurity, route);
}
private applyOAuth2ScopeSecurity(
security: Array<Record<string, Array<string>>>,
route: ExtractedRoute,
): Array<Record<string, Array<string>>> {
if (!route.oauth2RequiredScopes || route.oauth2RequiredScopes.length === 0 || !route.oauth2ScopeMode) {
if (route.oauth2BearerTokenRequired) {
return security.map((entry) => {
if (!('bearerToken' in entry)) {
return entry;
}
return {oauth2Token: []};
});
}
return security.filter((entry) => !('bearerToken' in entry));
}
if (route.oauth2ScopeMode === 'all') {
const scopes = [...route.oauth2RequiredScopes].sort();
return security.map((entry) => {
if (!('bearerToken' in entry)) {
return entry;
}
return {oauth2Token: scopes};
});
}
const sortedScopes = [...route.oauth2RequiredScopes].sort();
const transformed: Array<Record<string, Array<string>>> = [];
for (const entry of security) {
if (!('bearerToken' in entry)) {
transformed.push(entry);
continue;
}
for (const scope of sortedScopes) {
transformed.push({oauth2Token: [scope]});
}
}
return transformed;
}
private buildParameters(route: ExtractedRoute): Array<OpenAPIParameter> {
const parameters: Array<OpenAPIParameter> = [];
const seenParameters = new Set<string>();
function addParameter(parameter: OpenAPIParameter): void {
const key = `${parameter.in}:${parameter.name}`;
if (seenParameters.has(key)) {
return;
}
seenParameters.add(key);
parameters.push(parameter);
}
for (const pathParameter of extractPathParameters(route.path)) {
addParameter(pathParameter);
}
for (const validator of route.validators) {
if (validator.target !== 'query') {
continue;
}
if (validator.schemaName) {
for (const parameter of this.extractQueryParametersFromSchema(validator.schemaName)) {
addParameter(parameter);
}
continue;
}
if (validator.inlineSchema) {
for (const parameter of this.extractQueryParameters(validator)) {
addParameter(parameter);
}
}
}
return parameters;
}
private extractQueryParameters(validator: ExtractedValidator): Array<OpenAPIParameter> {
if (!validator.inlineSchema) {
return [];
}
const parameters: Array<OpenAPIParameter> = [];
const matches = validator.inlineSchema.matchAll(/(\w+):\s*([^,}]+)/g);
for (const match of matches) {
const name = match[1];
const typeString = match[2].trim();
const isOptional = typeString.includes('.optional()') || typeString.includes('.nullish()');
parameters.push({
name,
in: 'query',
required: !isOptional,
schema: this.inferSchemaFromTypeString(typeString),
});
}
return parameters;
}
private extractQueryParametersFromSchema(schemaName: string): Array<OpenAPIParameter> {
const loadedSchema = this.loadedSchemas.get(schemaName);
if (!loadedSchema) {
return [];
}
const schema = loadedSchema.openAPISchema;
if (schema.type !== 'object' || !schema.properties) {
return [];
}
const required = new Set(schema.required ?? []);
const parameters: Array<OpenAPIParameter> = [];
for (const [name, propertySchema] of Object.entries(schema.properties)) {
parameters.push({
name,
in: 'query',
required: required.has(name),
schema: propertySchema,
});
}
return parameters;
}
private inferSchemaFromTypeString(typeString: string): OpenAPISchemaOrRef {
function parseNumericSchema(targetTypeString: string): OpenAPISchema {
const isInteger = targetTypeString.includes('.int()');
const schema: OpenAPISchema = {
type: isInteger ? 'integer' : 'number',
};
const minMatch = targetTypeString.match(/\.min\((\d+)\)/);
const maxMatch = targetTypeString.match(/\.max\((\d+)\)/);
const defaultMatch = targetTypeString.match(/\.default\((\d+)\)/);
if (minMatch) {
schema.minimum = Number.parseInt(minMatch[1], 10);
}
if (maxMatch) {
schema.maximum = Number.parseInt(maxMatch[1], 10);
}
if (defaultMatch) {
schema.default = Number.parseInt(defaultMatch[1], 10);
}
return schema;
}
if (typeString.includes('UnsignedInt64Type')) {
return {type: 'string', format: 'int64', pattern: '^[0-9]+$'};
}
if (typeString.includes('Int64StringType')) {
return {type: 'string', format: 'int64', pattern: '^-?[0-9]+$'};
}
if (typeString.includes('Int64Type')) {
return {type: 'string', format: 'int64', pattern: '^-?[0-9]+$'};
}
if (typeString.includes('PermissionStringType') || typeString.includes('BitflagStringType')) {
return {type: 'string', format: 'int64', pattern: '^[0-9]+$'};
}
if (typeString.includes('SnowflakeStringType') || typeString.includes('SnowflakeType')) {
return SnowflakeTypeRef;
}
if (typeString.includes('z.coerce.number()') || typeString.includes('z.number()')) {
return parseNumericSchema(typeString);
}
if (typeString.includes('QueryBooleanType') || typeString.includes('z.boolean()')) {
return {type: 'boolean'};
}
if (typeString.includes('z.string()')) {
return {type: 'string'};
}
return {type: 'string'};
}
private getResponseSchema(schemaNameOrExpression: string): OpenAPISchema | {$ref: string} | null {
const trimmed = schemaNameOrExpression.trim();
const {baseExpression, isNullable} = this.stripNullability(trimmed);
const baseSchema = this.getBaseResponseSchema(baseExpression);
if (!baseSchema) {
return null;
}
if (!isNullable) {
return baseSchema;
}
return {anyOf: [baseSchema, {type: 'null'}]};
}
private getBaseResponseSchema(schemaNameOrExpression: string): OpenAPISchema | {$ref: string} | null {
if (this.schemaRegistry.has(schemaNameOrExpression)) {
return this.schemaRegistry.getRef(schemaNameOrExpression);
}
const trimmed = schemaNameOrExpression.trim();
if (/^z\s*\.null\(\)/.test(trimmed)) {
return {type: 'null'};
}
if (/^z\s*\.string\(\)/.test(trimmed)) {
return {type: 'string'};
}
if (/^z\s*\.array\(/.test(trimmed)) {
const inner = this.extractFirstCallArgument(trimmed);
if (!inner) {
return null;
}
const itemSchema = this.getResponseSchema(inner);
if (!itemSchema) {
return null;
}
return {type: 'array', items: itemSchema};
}
if (/^z\s*\.record\(/.test(trimmed)) {
const args = this.extractFirstCallArgument(trimmed);
if (!args) {
return null;
}
const [keyArg, valueArg] = this.splitTopLevel(args, ',', 2);
if (!keyArg || !valueArg) {
return null;
}
const valueSchema = this.getResponseSchema(valueArg);
if (!valueSchema) {
return null;
}
return {type: 'object', additionalProperties: valueSchema};
}
if (/^z\s*\.object\(/.test(trimmed)) {
return this.parseInlineSchema(trimmed);
}
return null;
}
private stripNullability(expression: string): {baseExpression: string; isNullable: boolean} {
let baseExpression = expression.trim();
let isNullable = false;
while (true) {
const match = baseExpression.match(/\.(nullable|nullish)\(\)\s*$/);
if (!match) {
break;
}
isNullable = true;
baseExpression = baseExpression.slice(0, match.index).trim();
}
return {baseExpression, isNullable};
}
private splitTopLevel(value: string, delimiter: string, maxParts?: number): Array<string> {
const parts: Array<string> = [];
let start = 0;
let parenDepth = 0;
let braceDepth = 0;
let bracketDepth = 0;
let inSingleQuote = false;
let inDoubleQuote = false;
let inTemplate = false;
function pushPart(end: number): void {
parts.push(value.slice(start, end).trim());
start = end + delimiter.length;
}
for (let i = 0; i < value.length; i++) {
const char = value[i];
if (!inDoubleQuote && !inTemplate && char === "'" && value[i - 1] !== '\\') {
inSingleQuote = !inSingleQuote;
} else if (!inSingleQuote && !inTemplate && char === '"' && value[i - 1] !== '\\') {
inDoubleQuote = !inDoubleQuote;
} else if (!inSingleQuote && !inDoubleQuote && char === '`' && value[i - 1] !== '\\') {
inTemplate = !inTemplate;
}
if (inSingleQuote || inDoubleQuote || inTemplate) {
continue;
}
if (char === '(') {
parenDepth++;
} else if (char === ')') {
parenDepth--;
} else if (char === '{') {
braceDepth++;
} else if (char === '}') {
braceDepth--;
} else if (char === '[') {
bracketDepth++;
} else if (char === ']') {
bracketDepth--;
}
if (char === delimiter && parenDepth === 0 && braceDepth === 0 && bracketDepth === 0) {
pushPart(i);
if (maxParts && parts.length >= maxParts - 1) {
break;
}
}
}
parts.push(value.slice(start).trim());
return parts;
}
private findTopLevelChar(value: string, target: string): number {
let parenDepth = 0;
let braceDepth = 0;
let bracketDepth = 0;
let inSingleQuote = false;
let inDoubleQuote = false;
let inTemplate = false;
for (let i = 0; i < value.length; i++) {
const char = value[i];
if (!inDoubleQuote && !inTemplate && char === "'" && value[i - 1] !== '\\') {
inSingleQuote = !inSingleQuote;
} else if (!inSingleQuote && !inTemplate && char === '"' && value[i - 1] !== '\\') {
inDoubleQuote = !inDoubleQuote;
} else if (!inSingleQuote && !inDoubleQuote && char === '`' && value[i - 1] !== '\\') {
inTemplate = !inTemplate;
}
if (inSingleQuote || inDoubleQuote || inTemplate) {
continue;
}
if (char === '(') {
parenDepth++;
} else if (char === ')') {
parenDepth--;
} else if (char === '{') {
braceDepth++;
} else if (char === '}') {
braceDepth--;
} else if (char === '[') {
bracketDepth++;
} else if (char === ']') {
bracketDepth--;
}
if (char === target && parenDepth === 0 && braceDepth === 0 && bracketDepth === 0) {
return i;
}
}
return -1;
}
private extractFirstCallArgument(expression: string): string | null {
const openIndex = expression.indexOf('(');
if (openIndex === -1) {
return null;
}
let depth = 0;
for (let i = openIndex; i < expression.length; i++) {
const char = expression[i];
if (char === '(') {
depth++;
} else if (char === ')') {
depth--;
if (depth === 0) {
return expression.slice(openIndex + 1, i);
}
}
}
return null;
}
private buildRequestBody(route: ExtractedRoute): OpenAPIRequestBody | undefined {
const jsonValidator = route.validators.find((validator) => validator.target === 'json');
const formValidator = route.validators.find((validator) => validator.target === 'form');
if (!jsonValidator && !formValidator) {
return undefined;
}
function isOptionalSchema(schema: string | null | undefined): boolean {
if (!schema) {
return false;
}
return /\.(nullable|nullish|optional)\(\)\s*$/.test(schema.trim());
}
const requestBody: OpenAPIRequestBody = {
required: !(isOptionalSchema(jsonValidator?.inlineSchema) || isOptionalSchema(formValidator?.inlineSchema)),
content: {},
};
const setContent = (
contentType: 'application/json' | 'multipart/form-data',
validator: ExtractedValidator,
): void => {
if (validator.schemaName) {
requestBody.content[contentType] = {
schema: this.schemaRegistry.has(validator.schemaName)
? this.schemaRegistry.getRef(validator.schemaName)
: {type: 'object'},
};
return;
}
if (validator.inlineSchema) {
requestBody.content[contentType] = {
schema: this.parseInlineSchema(validator.inlineSchema),
};
}
};
if (jsonValidator) {
setContent('application/json', jsonValidator);
}
if (formValidator) {
setContent('multipart/form-data', formValidator);
}
return requestBody;
}
private parseInlineSchema(schemaString: string): OpenAPISchema {
const objectArgument = this.extractFirstCallArgument(schemaString);
if (!objectArgument) {
return {type: 'object'};
}
const trimmed = objectArgument.trim();
if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) {
return {type: 'object'};
}
const body = trimmed.slice(1, -1).trim();
if (body.length === 0) {
return {type: 'object', properties: {}};
}
const properties: Record<string, OpenAPISchemaOrRef> = {};
const required: Array<string> = [];
for (const entry of this.splitTopLevel(body, ',')) {
if (!entry) {
continue;
}
const colonIndex = this.findTopLevelChar(entry, ':');
if (colonIndex === -1) {
continue;
}
const key = entry.slice(0, colonIndex).trim();
const value = entry.slice(colonIndex + 1).trim();
if (!key || !value) {
continue;
}
const rawName = key.startsWith("'") || key.startsWith('"') ? key.slice(1, -1) : key;
const name = rawName.trim();
if (!name) {
continue;
}
properties[name] = this.inferSchemaFromTypeString(value);
const isOptional =
/\.(optional|nullish)\(\)/.test(value) || /\.(default|catch)\(/.test(value) || /^z\.optional\(/.test(value);
if (!isOptional) {
required.push(name);
}
}
const result: OpenAPISchema = {
type: 'object',
properties,
};
if (required.length > 0) {
result.required = required;
}
return result;
}
private buildResponses(
route: ExtractedRoute,
explicitStatusCodes: Array<number> | null,
): Record<string, OpenAPIResponse> {
const requiresAuth = this.buildSecurity(route).length > 0;
const responses: Record<string, OpenAPIResponse> = {};
const successStatusCodes =
explicitStatusCodes && explicitStatusCodes.length > 0
? explicitStatusCodes
: route.successStatusCodes.length > 0
? route.successStatusCodes
: route.hasNoContent
? [204]
: [200];
for (const code of successStatusCodes) {
if (code === 204 || (route.hasNoContent && !route.responseSchemaName)) {
responses['204'] = getNoContentResponse();
continue;
}
let responseSchema: OpenAPISchema | {$ref: string} = {type: 'object'};
if (route.responseSchemaName) {
const resolved = this.getResponseSchema(route.responseSchemaName);
if (resolved) {
responseSchema = resolved;
} else {
console.warn(
`Warning: Response schema '${route.responseSchemaName}' not found for ${route.method.toUpperCase()} ${route.path}`,
);
}
}
responses[String(code)] = {
description: 'Success',
content: {
'application/json': {
schema: responseSchema,
},
},
};
}
Object.assign(responses, getErrorResponses(requiresAuth));
return responses;
}
}

View File

@@ -0,0 +1,66 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {OpenAPIPathItem, OpenAPISchema} from '@fluxer/openapi/src/OpenAPITypes';
const OPENAPI_SCHEMA_REF_PATTERN = /#\/components\/schemas\/([A-Za-z0-9_]+)/;
export function collectReferencedSchemaNames(
paths: Record<string, OpenAPIPathItem>,
allSchemas: Record<string, OpenAPISchema>,
): Set<string> {
const referenced = new Set<string>();
function extractRefs(value: unknown): void {
if (value == null || typeof value !== 'object') {
return;
}
if ('$ref' in value && typeof (value as {$ref: string}).$ref === 'string') {
const ref = (value as {$ref: string}).$ref;
const match = ref.match(OPENAPI_SCHEMA_REF_PATTERN);
if (match) {
const schemaName = match[1];
if (!referenced.has(schemaName)) {
referenced.add(schemaName);
if (allSchemas[schemaName]) {
extractRefs(allSchemas[schemaName]);
}
}
}
}
if (Array.isArray(value)) {
for (const item of value) {
extractRefs(item);
}
return;
}
for (const nested of Object.values(value as Record<string, unknown>)) {
extractRefs(nested);
}
}
extractRefs(paths);
referenced.add('Error');
return referenced;
}

View File

@@ -0,0 +1,74 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {getRegisteredBitflagSchemas, getRegisteredInt32EnumSchemas} from '@fluxer/openapi/src/converters/ZodToOpenAPI';
import {OpenAPIGeneratorCatalog} from '@fluxer/openapi/src/generator/OpenAPIGeneratorCatalog';
import {type LoadedSchema, loadSchemas} from '@fluxer/openapi/src/registry/SchemaLoader';
import type {SchemaRegistry} from '@fluxer/openapi/src/registry/SchemaRegistry';
import {CustomSchemaType} from '@fluxer/openapi/src/schemas/CustomSchemaType';
export interface OpenAPISchemaRegistryLoadResult {
readonly loadedSchemas: Map<string, LoadedSchema>;
readonly totalRegisteredSchemas: number;
}
export async function loadSchemasIntoRegistry(
basePath: string,
schemaRegistry: SchemaRegistry,
): Promise<OpenAPISchemaRegistryLoadResult> {
for (const [name, schema] of OpenAPIGeneratorCatalog.builtInSchemas) {
schemaRegistry.register(name, schema);
}
for (const [name, schema] of Object.entries(CustomSchemaType.getAllSchemas())) {
if (!schemaRegistry.has(name)) {
schemaRegistry.register(name, schema);
}
}
let loadedSchemas = new Map<string, LoadedSchema>();
try {
const dynamicSchemas = await loadSchemas(basePath);
loadedSchemas = dynamicSchemas;
for (const [name, schema] of dynamicSchemas) {
if (!schemaRegistry.has(name)) {
schemaRegistry.register(name, schema.openAPISchema);
}
}
} catch (error) {
console.warn('Warning: Could not load some schemas:', error);
}
for (const [name, schema] of Object.entries(getRegisteredBitflagSchemas())) {
if (!schemaRegistry.has(name)) {
schemaRegistry.register(name, schema);
}
}
for (const [name, schema] of Object.entries(getRegisteredInt32EnumSchemas())) {
if (!schemaRegistry.has(name)) {
schemaRegistry.register(name, schema);
}
}
return {
loadedSchemas,
totalRegisteredSchemas: Object.keys(schemaRegistry.getAllSchemas()).length,
};
}

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

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

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

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

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

View 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();

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

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

View File

@@ -0,0 +1,10 @@
{
"extends": "../../tsconfigs/base.json",
"compilerOptions": {
"lib": ["ESNext"],
"paths": {
"@fluxer/*": ["./../../packages/*"]
}
},
"include": ["src/**/*.tsx", "src/**/*.ts"]
}