refactor(schema): simplify validation and OpenAPI generation (#2709)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-11 23:24:26 +02:00
1 parent 4bc5593f9f
commit de1fd95a99
510 files changed
+29494 -36131

No files matched your search

@@ -1,49 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
APIErrorCodeSchema,
Base64ImageTypeSchema,
DiscriminatorTypeSchema,
EmailTypeSchema,
Int32TypeSchema,
Int64StringTypeSchema,
Int64TypeSchema,
LocaleSchema,
NonNegativeSafeIntegerTypeSchema,
PasswordTypeSchema,
PhoneNumberTypeSchema,
SnowflakeTypeSchema,
UnsignedInt64TypeSchema,
UsernameTypeSchema,
ValidationErrorItemSchema,
} from '@fluxer/openapi/src/converters/BuiltInSchemas';
import {APIErrorCodeSchema} from '@fluxer/openapi/src/converters/BuiltInSchemas';
import type {OpenAPISchema, OpenAPISecurityScheme} from '@fluxer/openapi/src/OpenAPITypes';
import {ERROR_SCHEMA} from '@fluxer/openapi/src/registry/ResponseRegistry';
import {ERROR_SCHEMA, THROTTLED_ERROR_SCHEMA} from '@fluxer/openapi/src/registry/ResponseRegistry';
const ORDERED_TAG_NAMES = [
'Auth',
'Users',
'Guilds',
'Channels',
'Invites',
'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 name: string;
readonly description: string;
}
const TAG_DEFINITIONS: ReadonlyArray<TagDefinition> = [
@@ -69,13 +30,7 @@ const TAG_DEFINITIONS: ReadonlyArray<TagDefinition> = [
{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 TAG_DESCRIPTIONS = Object.fromEntries(TAG_DEFINITIONS.map(({name, description}) => [name, description]));
const SECURITY_SCHEMES: Record<string, OpenAPISecurityScheme> = {
botToken: {
type: 'apiKey',
@@ -123,21 +78,8 @@ const SECURITY_SCHEMES: Record<string, OpenAPISecurityScheme> = {
};
const BUILT_IN_SCHEMAS: ReadonlyArray<readonly [string, OpenAPISchema]> = [
['Error', ERROR_SCHEMA],
['ThrottledError', THROTTLED_ERROR_SCHEMA],
['APIErrorCode', APIErrorCodeSchema],
['SnowflakeType', SnowflakeTypeSchema],
['Int32Type', Int32TypeSchema],
['NonNegativeSafeIntegerType', NonNegativeSafeIntegerTypeSchema],
['Int64Type', Int64TypeSchema],
['Int64StringType', Int64StringTypeSchema],
['UnsignedInt64Type', UnsignedInt64TypeSchema],
['UsernameType', UsernameTypeSchema],
['DiscriminatorType', DiscriminatorTypeSchema],
['EmailType', EmailTypeSchema],
['PasswordType', PasswordTypeSchema],
['PhoneNumberType', PhoneNumberTypeSchema],
['Base64ImageType', Base64ImageTypeSchema],
['Locale', LocaleSchema],
['ValidationErrorItem', ValidationErrorItemSchema],
];
interface IOpenAPIGeneratorCatalog {
readonly excluded: {
@@ -157,7 +99,7 @@ export const OpenAPIGeneratorCatalog: IOpenAPIGeneratorCatalog = {
paths: new Set<string>(['/_rpc', '/oauth2/authorize']),
},
tags: {
order: ORDERED_TAG_NAMES,
order: TAG_DEFINITIONS.map(({name}) => name),
descriptions: TAG_DESCRIPTIONS,
},
securitySchemes: SECURITY_SCHEMES,
@@ -1,33 +1,31 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {SnowflakeTypeRef} from '@fluxer/openapi/src/converters/BuiltInSchemas';
import {analyzeSecurityRequirements} from '@fluxer/openapi/src/extractors/MiddlewareAnalyzer';
import {extractPathParameterNames} from '@fluxer/openapi/src/extractors/PathParameters';
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 {getErrorResponses} from '@fluxer/openapi/src/registry/ResponseRegistry';
import type {SchemaRegistry} from '@fluxer/openapi/src/registry/SchemaRegistry';
interface OpenAPIOperationBuilderDependencies {
readonly schemaRegistry: SchemaRegistry;
readonly loadedSchemas: Map<string, LoadedSchema>;
readonly usedOperationIds: Set<string>;
}
function successStatusCodes(route: ExtractedRoute): Array<number> {
if (route.explicitStatusCodes?.length) return route.explicitStatusCodes;
if (route.successStatusCodes.length) return route.successStatusCodes;
return [route.hasNoContent ? 204 : 200];
}
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 {
@@ -46,18 +44,16 @@ export class OpenAPIOperationBuilder {
`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 baseSecurity = route.explicitSecurity?.map((scheme) => ({[scheme]: []})) ?? this.buildSecurity(route);
const security = this.applyOAuth2ScopeSecurity(baseSecurity, route);
const parameters = this.buildParameters(route);
const requestBody = this.buildRequestBody(route);
const responses = this.buildResponses(route, route.explicitStatusCodes);
const responses = this.buildResponses(route, security.length > 0);
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;
@@ -80,46 +76,27 @@ export class OpenAPIOperationBuilder {
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);
if (this.usedOperationIds.has(baseId)) throw new Error(`Duplicate operationId: ${baseId}`);
this.usedOperationIds.add(baseId);
return baseId;
}
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);
return [{adminApiKey: []}];
}
if (route.path === '/applications/@me') {
baseSecurity = [{botToken: []}];
return this.applyOAuth2ScopeSecurity(baseSecurity, route);
return [{botToken: []}];
}
if (route.path === '/users/@me' || route.path.startsWith('/users/@me/')) {
baseSecurity = [{bearerToken: []}, {sessionToken: []}];
return this.applyOAuth2ScopeSecurity(baseSecurity, route);
return [{bearerToken: []}, {sessionToken: []}];
}
const security = analyzeSecurityRequirements(route);
if (security.type !== 'bearer') {
if (!route.hasLoginRequired && !route.hasLoginRequiredAllowSuspicious && !route.hasDefaultUserOnly) {
return [];
}
if (route.hasDefaultUserOnly) {
baseSecurity = [{bearerToken: []}, {sessionToken: []}];
return this.applyOAuth2ScopeSecurity(baseSecurity, route);
return [{bearerToken: []}, {sessionToken: []}];
}
baseSecurity = [{botToken: []}, {bearerToken: []}, {sessionToken: []}];
return this.applyOAuth2ScopeSecurity(baseSecurity, route);
return [{botToken: []}, {bearerToken: []}, {sessionToken: []}];
}
private applyOAuth2ScopeSecurity(
security: Array<Record<string, Array<string>>>,
@@ -159,460 +136,87 @@ export class OpenAPIOperationBuilder {
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);
}
const parameters = new Map<string, OpenAPIParameter>();
for (const validator of route.validators) {
if (validator.target !== 'query') {
continue;
if (validator.target === 'json' || validator.target === 'form') continue;
const location = validator.target === 'param' ? 'path' : validator.target;
const schemaName = validator.schemaName;
const schema = this.schemaRegistry.get(schemaName);
if (!schema || schema.type !== 'object' || !schema.properties) {
throw new Error(`Parameter schema must be an object: ${schemaName}`);
}
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);
}
const required = new Set(schema.required ?? []);
for (const [name, property] of Object.entries(schema.properties)) {
const propertySchema: OpenAPISchema = typeof property === 'boolean' ? (property ? {} : {not: {}}) : property;
const key = `${location}:${name}`;
if (parameters.has(key)) throw new Error(`Duplicate parameter ${key} for ${route.method} ${route.path}`);
parameters.set(key, {
name,
in: location,
required: location === 'path' || required.has(name),
schema: propertySchema,
...(propertySchema.description ? {description: propertySchema.description} : {}),
});
}
}
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('NonNegativeSafeIntegerType')) {
return {type: 'integer', minimum: 0, maximum: 9007199254740991, format: 'int53'};
}
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);
}
for (const name of extractPathParameterNames(route.path)) {
if (!parameters.has(`path:${name}`)) {
throw new Error(`Path parameter ${name} has no validator for ${route.method.toUpperCase()} ${route.path}`);
}
}
return null;
return [...parameters.values()];
}
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) {
if (route.explicitRequestSchemaName || route.explicitRequestFormSchemaName) {
const requestBody: OpenAPIRequestBody = {required: true, content: {}};
if (route.explicitRequestSchemaName) {
requestBody.content['application/json'] = {
schema: this.schemaRegistry.has(route.explicitRequestSchemaName)
? this.schemaRegistry.getRef(route.explicitRequestSchemaName)
: {type: 'object'},
};
}
if (route.explicitRequestFormSchemaName) {
requestBody.content['multipart/form-data'] = {
schema: this.schemaRegistry.has(route.explicitRequestFormSchemaName)
? this.schemaRegistry.getRef(route.explicitRequestFormSchemaName)
: {type: 'object'},
};
}
return requestBody;
}
return undefined;
}
function isOptionalSchema(schema: string | null | undefined): boolean {
if (!schema) {
return false;
}
return /\.(nullable|nullish|optional)\(\)\s*$/.test(schema.trim());
}
const jsonSchemaName = route.explicitRequestSchemaName ?? jsonValidator?.schemaName;
const formSchemaName = route.explicitRequestFormSchemaName ?? formValidator?.schemaName;
if (!jsonSchemaName && !formSchemaName) return undefined;
const requestBody: OpenAPIRequestBody = {
required: !(isOptionalSchema(jsonValidator?.inlineSchema) || isOptionalSchema(formValidator?.inlineSchema)),
required:
route.explicitRequestBodyRequired ??
!(jsonValidator && jsonSchemaName && this.schemaRegistry.acceptsEmptyObject(jsonSchemaName)),
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);
for (const [contentType, schemaName] of [
['application/json', jsonSchemaName],
['multipart/form-data', formSchemaName],
] as const) {
if (!schemaName) continue;
requestBody.content[contentType] = {schema: this.schemaRegistry.getRef(schemaName)};
}
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;
private buildResponses(route: ExtractedRoute, requiresAuth: boolean): Record<string, OpenAPIResponse> {
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();
const statusCodes = successStatusCodes(route);
for (const code of route.bodylessStatusCodes) {
if (!statusCodes.includes(code)) {
throw new Error(`Bodyless status ${code} is not declared for ${route.method.toUpperCase()} ${route.path}`);
}
}
for (const code of statusCodes) {
if (code === 304) {
responses['304'] = {description: 'Not Modified'};
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}`,
);
}
if (
code === 204 ||
route.bodylessStatusCodes.includes(code) ||
(route.hasNoContent && !route.responseSchemaName)
) {
responses[String(code)] = {description: code === 204 ? 'No Content' : 'Success'};
continue;
}
if (!route.responseSchemaName) {
throw new Error(`Missing response schema for ${route.method.toUpperCase()} ${route.path}`);
}
responses[String(code)] = {
description: 'Success',
content: {
'application/json': {
schema: responseSchema,
},
[route.responseContentType]: {schema: this.schemaRegistry.getRef(route.responseSchemaName, 'output')},
},
};
}
@@ -1,54 +1,31 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {visitOpenAPISchemaObjects} from '@fluxer/openapi/src/OpenAPISchemaVisitor';
import type {OpenAPIPathItem, OpenAPISchema} from '@fluxer/openapi/src/OpenAPITypes';
const OPENAPI_SCHEMA_REF_PATTERN = /#\/components\/schemas\/([A-Za-z0-9_]+)/;
const SCHEMA_REFERENCE_PREFIX = '#/components/schemas/';
export function collectReferencedSchemaNames(
paths: Record<string, OpenAPIPathItem>,
allSchemas: Record<string, OpenAPISchema>,
): Set<string> {
const referenced = new Set<string>();
function addSchema(schemaName: string): void {
if (referenced.has(schemaName)) {
return;
}
referenced.add(schemaName);
if (allSchemas[schemaName]) {
extractRefs(allSchemas[schemaName]);
}
}
function extractRefs(value: unknown): void {
if (value == null || typeof value !== 'object') {
return;
}
if (
'$ref' in value &&
typeof (
value as {
$ref: string;
const pending: Array<unknown> = [{paths}];
while (pending.length > 0) {
const value = pending.pop();
visitOpenAPISchemaObjects(value, (schema) => {
if (!schema.$ref?.startsWith(SCHEMA_REFERENCE_PREFIX)) return;
const schemaName = decodeURIComponent(schema.$ref.slice(SCHEMA_REFERENCE_PREFIX.length).split('/')[0])
.replaceAll('~1', '/')
.replaceAll('~0', '~');
if (!referenced.has(schemaName)) {
if (!Object.hasOwn(allSchemas, schemaName)) {
throw new Error(`Reference to an unregistered OpenAPI schema: ${schema.$ref}`);
}
).$ref === 'string'
) {
const ref = (
value as {
$ref: string;
}
).$ref;
const match = ref.match(OPENAPI_SCHEMA_REF_PATTERN);
if (match) {
addSchema(match[1]);
referenced.add(schemaName);
pending.push(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);
addSchema('Error');
return referenced;
}
@@ -1,65 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
getRegisteredBitflagSchemas,
getRegisteredDiscriminatedUnionBranchSchemas,
getRegisteredInt32EnumSchemas,
getRegisteredNamedObjectSchemas,
} 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 {loadSchemas} from '@fluxer/openapi/src/registry/SchemaLoader';
import type {SchemaRegistry} from '@fluxer/openapi/src/registry/SchemaRegistry';
import {CustomSchemaType} from '@fluxer/openapi/src/schemas/CustomSchemaType';
interface OpenAPISchemaRegistryLoadResult {
readonly loadedSchemas: Map<string, LoadedSchema>;
readonly totalRegisteredSchemas: number;
}
export async function loadSchemasIntoRegistry(
basePath: string,
schemaRegistry: SchemaRegistry,
): Promise<OpenAPISchemaRegistryLoadResult> {
export async function loadSchemasIntoRegistry(basePath: string, schemaRegistry: SchemaRegistry): Promise<void> {
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);
}
for (const [name, zodSchema] of await loadSchemas(basePath)) {
schemaRegistry.registerZod(name, zodSchema);
}
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);
}
}
for (const [name, schema] of Object.entries(getRegisteredDiscriminatedUnionBranchSchemas())) {
if (!schemaRegistry.has(name)) {
schemaRegistry.register(name, schema);
}
}
for (const [name, schema] of Object.entries(getRegisteredNamedObjectSchemas())) {
if (!schemaRegistry.has(name)) {
schemaRegistry.register(name, schema);
}
}
return {
loadedSchemas,
totalRegisteredSchemas: Object.keys(schemaRegistry.getAllSchemas()).length,
};
}