598 lines
18 KiB
Dart
598 lines
18 KiB
Dart
/// 增强的 OpenAPI 验证器
|
|
/// 集成详细的错误报告和修复建议
|
|
library;
|
|
|
|
import '../core/error_reporter.dart';
|
|
import '../core/models.dart';
|
|
|
|
/// 增强的 OpenAPI 验证器
|
|
class EnhancedValidator {
|
|
final ErrorReporter _errorReporter;
|
|
final bool _includeWarnings;
|
|
|
|
EnhancedValidator({
|
|
bool includeWarnings = true,
|
|
}) : _errorReporter = ErrorReporter(),
|
|
_includeWarnings = includeWarnings;
|
|
|
|
/// 获取错误报告器
|
|
ErrorReporter get errorReporter => _errorReporter;
|
|
|
|
/// 验证 OpenAPI 文档
|
|
bool validateDocument(SwaggerDocument document) {
|
|
_errorReporter.clear();
|
|
|
|
// 基础结构验证
|
|
_validateBasicStructure(document);
|
|
|
|
// 路径验证
|
|
_validatePaths(document);
|
|
|
|
// 组件验证
|
|
_validateComponents(document);
|
|
|
|
// 安全方案验证
|
|
_validateSecurity(document);
|
|
|
|
// 最佳实践检查
|
|
if (_includeWarnings) {
|
|
_checkBestPractices(document);
|
|
}
|
|
|
|
return !_errorReporter.hasErrorsOrCritical;
|
|
}
|
|
|
|
/// 验证基础结构
|
|
void _validateBasicStructure(SwaggerDocument document) {
|
|
// 验证标题
|
|
if (document.title.isEmpty) {
|
|
_errorReporter.reportError(
|
|
id: 'MISSING_INFO_TITLE',
|
|
title: 'Missing API Title',
|
|
description: 'API title is required in the info object.',
|
|
severity: ErrorSeverity.error,
|
|
category: ErrorCategory.validation,
|
|
jsonPath: 'info.title',
|
|
suggestions: [
|
|
FixSuggestion(
|
|
description: 'Add a descriptive title for your API',
|
|
codeExample: '"title": "My API"',
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
// 验证版本
|
|
if (document.version.isEmpty) {
|
|
_errorReporter.reportError(
|
|
id: 'MISSING_INFO_VERSION',
|
|
title: 'Missing API Version',
|
|
description: 'API version is required in the info object.',
|
|
severity: ErrorSeverity.error,
|
|
category: ErrorCategory.validation,
|
|
jsonPath: 'info.version',
|
|
suggestions: [
|
|
FixSuggestion(
|
|
description: 'Add a version number using semantic versioning',
|
|
codeExample: '"version": "1.0.0"',
|
|
documentationUrl: 'https://semver.org/',
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
// 验证描述
|
|
if (document.description.isEmpty && _includeWarnings) {
|
|
_errorReporter.reportError(
|
|
id: 'MISSING_INFO_DESCRIPTION',
|
|
title: 'Missing API Description',
|
|
description:
|
|
'API description helps users understand the purpose of your API.',
|
|
severity: ErrorSeverity.warning,
|
|
category: ErrorCategory.bestPractice,
|
|
jsonPath: 'info.description',
|
|
suggestions: [
|
|
FixSuggestion(
|
|
description: 'Add a description explaining what your API does',
|
|
codeExample:
|
|
'"description": "This API provides user management functionality"',
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
// 验证服务器配置
|
|
if (document.servers.isEmpty && _includeWarnings) {
|
|
_errorReporter.reportError(
|
|
id: 'MISSING_SERVERS',
|
|
title: 'Missing Server Configuration',
|
|
description:
|
|
'Server configuration helps clients know where to send requests.',
|
|
severity: ErrorSeverity.warning,
|
|
category: ErrorCategory.bestPractice,
|
|
jsonPath: 'servers',
|
|
suggestions: [
|
|
FixSuggestion(
|
|
description: 'Add at least one server configuration',
|
|
codeExample:
|
|
'"servers": [{"url": "https://api.example.com", "description": "Production server"}]',
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 验证路径
|
|
void _validatePaths(SwaggerDocument document) {
|
|
if (document.paths.isEmpty) {
|
|
_errorReporter.reportError(
|
|
id: 'EMPTY_PATHS',
|
|
title: 'Empty Paths Object',
|
|
description: 'OpenAPI document must contain at least one path.',
|
|
severity: ErrorSeverity.error,
|
|
category: ErrorCategory.validation,
|
|
jsonPath: 'paths',
|
|
suggestions: [
|
|
FixSuggestion(
|
|
description: 'Add at least one API endpoint',
|
|
codeExample:
|
|
'"/users": { "get": { "responses": { "200": { "description": "Success" } } } }',
|
|
),
|
|
],
|
|
);
|
|
return;
|
|
}
|
|
|
|
document.paths.forEach((pathPattern, apiPath) {
|
|
_validatePath(pathPattern, apiPath);
|
|
});
|
|
}
|
|
|
|
/// 验证单个路径
|
|
void _validatePath(String pathPattern, ApiPath apiPath) {
|
|
final pathKey = 'paths["$pathPattern"][${apiPath.method.value}]';
|
|
|
|
// 验证路径格式
|
|
if (!pathPattern.startsWith('/')) {
|
|
_errorReporter.reportError(
|
|
id: 'INVALID_PATH_FORMAT',
|
|
title: 'Invalid Path Format',
|
|
description: 'Path must start with a forward slash.',
|
|
severity: ErrorSeverity.error,
|
|
category: ErrorCategory.syntax,
|
|
jsonPath: pathKey,
|
|
snippet: pathPattern,
|
|
suggestions: [
|
|
FixSuggestion(
|
|
description: 'Ensure path starts with /',
|
|
codeExample: '"/$pathPattern" instead of "$pathPattern"',
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
// 验证响应
|
|
if (apiPath.responses.isEmpty) {
|
|
_errorReporter.reportError(
|
|
id: 'MISSING_OPERATION_RESPONSES',
|
|
title: 'Missing Operation Responses',
|
|
description: 'Every operation must define at least one response.',
|
|
severity: ErrorSeverity.error,
|
|
category: ErrorCategory.validation,
|
|
jsonPath: '$pathKey.responses',
|
|
suggestions: [
|
|
FixSuggestion(
|
|
description: 'Add at least a default response',
|
|
codeExample: '"responses": { "200": { "description": "Success" } }',
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
// 验证操作 ID
|
|
if (apiPath.operationId.isEmpty && _includeWarnings) {
|
|
_errorReporter.reportError(
|
|
id: 'MISSING_OPERATION_ID',
|
|
title: 'Missing Operation ID',
|
|
description:
|
|
'Operation should have an operationId for better code generation.',
|
|
severity: ErrorSeverity.warning,
|
|
category: ErrorCategory.bestPractice,
|
|
jsonPath: '$pathKey.operationId',
|
|
suggestions: [
|
|
FixSuggestion(
|
|
description: 'Add a unique operationId',
|
|
codeExample:
|
|
'"operationId": "${_generateOperationId(pathPattern, apiPath.method)}"',
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
// 验证摘要
|
|
if (apiPath.summary.isEmpty && _includeWarnings) {
|
|
_errorReporter.reportError(
|
|
id: 'MISSING_OPERATION_SUMMARY',
|
|
title: 'Missing Operation Summary',
|
|
description:
|
|
'Operation should have a summary for better documentation.',
|
|
severity: ErrorSeverity.info,
|
|
category: ErrorCategory.bestPractice,
|
|
jsonPath: '$pathKey.summary',
|
|
suggestions: [
|
|
FixSuggestion(
|
|
description: 'Add a brief summary',
|
|
codeExample: '"summary": "Get all users"',
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
// 验证参数
|
|
_validateParameters(apiPath.parameters, pathKey, pathPattern);
|
|
|
|
// 验证响应
|
|
_validateResponses(apiPath.responses, pathKey);
|
|
}
|
|
|
|
/// 验证参数
|
|
void _validateParameters(
|
|
List<ApiParameter> parameters, String pathKey, String pathPattern) {
|
|
// 提取路径参数
|
|
final pathParams = _extractPathParameters(pathPattern);
|
|
final declaredPathParams = parameters
|
|
.where((p) => p.location == ParameterLocation.path)
|
|
.map((p) => p.name)
|
|
.toSet();
|
|
|
|
// 检查路径参数是否都有声明
|
|
for (final param in pathParams) {
|
|
if (!declaredPathParams.contains(param)) {
|
|
_errorReporter.reportError(
|
|
id: 'UNDECLARED_PATH_PARAMETER',
|
|
title: 'Undeclared Path Parameter',
|
|
description:
|
|
'Path parameter "$param" is used in the path but not declared in parameters.',
|
|
severity: ErrorSeverity.error,
|
|
category: ErrorCategory.validation,
|
|
jsonPath: '$pathKey.parameters',
|
|
suggestions: [
|
|
FixSuggestion(
|
|
description: 'Add parameter declaration',
|
|
codeExample:
|
|
'{"name": "$param", "in": "path", "required": true, "schema": {"type": "string"}}',
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
// 验证每个参数
|
|
for (int i = 0; i < parameters.length; i++) {
|
|
final param = parameters[i];
|
|
final paramPath = '$pathKey.parameters[$i]';
|
|
|
|
// 验证参数名
|
|
if (param.name.isEmpty) {
|
|
_errorReporter.reportError(
|
|
id: 'MISSING_PARAMETER_NAME',
|
|
title: 'Missing Parameter Name',
|
|
description: 'Parameter must have a name.',
|
|
severity: ErrorSeverity.error,
|
|
category: ErrorCategory.validation,
|
|
jsonPath: '$paramPath.name',
|
|
suggestions: [
|
|
FixSuggestion(
|
|
description: 'Add a name for the parameter',
|
|
codeExample: '"name": "userId"',
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
// 验证路径参数必须是必需的
|
|
if (param.location == ParameterLocation.path && !param.required) {
|
|
_errorReporter.reportError(
|
|
id: 'PATH_PARAMETER_NOT_REQUIRED',
|
|
title: 'Path Parameter Not Required',
|
|
description: 'Path parameters must be marked as required.',
|
|
severity: ErrorSeverity.error,
|
|
category: ErrorCategory.validation,
|
|
jsonPath: '$paramPath.required',
|
|
suggestions: [
|
|
FixSuggestion(
|
|
description: 'Set required: true for path parameters',
|
|
codeExample: '"required": true',
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 验证响应
|
|
void _validateResponses(Map<String, ApiResponse> responses, String pathKey) {
|
|
bool hasSuccessResponse = false;
|
|
bool hasErrorResponse = false;
|
|
|
|
responses.forEach((code, response) {
|
|
final responsePath = '$pathKey.responses["$code"]';
|
|
final statusCode = int.tryParse(code) ?? 0;
|
|
|
|
// 检查成功响应
|
|
if (statusCode >= 200 && statusCode < 300) {
|
|
hasSuccessResponse = true;
|
|
}
|
|
|
|
// 检查错误响应
|
|
if (statusCode >= 400) {
|
|
hasErrorResponse = true;
|
|
}
|
|
|
|
// 验证响应描述
|
|
if (response.description.isEmpty) {
|
|
_errorReporter.reportError(
|
|
id: 'MISSING_RESPONSE_DESCRIPTION',
|
|
title: 'Missing Response Description',
|
|
description: 'Response should have a description.',
|
|
severity: ErrorSeverity.warning,
|
|
category: ErrorCategory.bestPractice,
|
|
jsonPath: '$responsePath.description',
|
|
suggestions: [
|
|
FixSuggestion(
|
|
description: 'Add a description for the response',
|
|
codeExample: '"description": "Successful operation"',
|
|
),
|
|
],
|
|
);
|
|
}
|
|
});
|
|
|
|
// 检查是否有成功响应
|
|
if (!hasSuccessResponse && _includeWarnings) {
|
|
_errorReporter.reportError(
|
|
id: 'NO_SUCCESS_RESPONSE',
|
|
title: 'No Success Response',
|
|
description:
|
|
'Operation should define at least one success response (2xx).',
|
|
severity: ErrorSeverity.warning,
|
|
category: ErrorCategory.bestPractice,
|
|
jsonPath: '$pathKey.responses',
|
|
suggestions: [
|
|
FixSuggestion(
|
|
description: 'Add a success response',
|
|
codeExample: '"200": { "description": "Success" }',
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
// 检查是否有错误响应
|
|
if (!hasErrorResponse && _includeWarnings) {
|
|
_errorReporter.reportError(
|
|
id: 'NO_ERROR_RESPONSE',
|
|
title: 'No Error Response',
|
|
description:
|
|
'Consider adding error responses (4xx/5xx) for better API documentation.',
|
|
severity: ErrorSeverity.info,
|
|
category: ErrorCategory.bestPractice,
|
|
jsonPath: '$pathKey.responses',
|
|
suggestions: [
|
|
FixSuggestion(
|
|
description: 'Add common error responses',
|
|
codeExample:
|
|
'"400": { "description": "Bad Request" }, "404": { "description": "Not Found" }',
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 验证组件
|
|
void _validateComponents(SwaggerDocument document) {
|
|
// 验证 schemas
|
|
document.components.schemas.forEach((name, model) {
|
|
_validateSchema(name, model);
|
|
});
|
|
|
|
// 验证安全方案
|
|
document.components.securitySchemes.forEach((name, scheme) {
|
|
_validateSecurityScheme(name, scheme);
|
|
});
|
|
}
|
|
|
|
/// 验证 Schema
|
|
void _validateSchema(String name, ApiModel model) {
|
|
final schemaPath = 'components.schemas["$name"]';
|
|
|
|
// 验证模型名称
|
|
if (model.name.isEmpty) {
|
|
_errorReporter.reportError(
|
|
id: 'MISSING_SCHEMA_NAME',
|
|
title: 'Missing Schema Name',
|
|
description: 'Schema should have a name.',
|
|
severity: ErrorSeverity.error,
|
|
category: ErrorCategory.validation,
|
|
jsonPath: schemaPath,
|
|
suggestions: [
|
|
FixSuggestion(
|
|
description: 'Ensure schema has a valid name',
|
|
codeExample:
|
|
'Schema name should match the key in components.schemas',
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
// 检查属性数量
|
|
if (model.properties.length > 20 && _includeWarnings) {
|
|
_errorReporter.reportError(
|
|
id: 'LARGE_SCHEMA_OBJECT',
|
|
title: 'Large Schema Object',
|
|
description:
|
|
'Schema has many properties (${model.properties.length}), consider breaking it down.',
|
|
severity: ErrorSeverity.info,
|
|
category: ErrorCategory.performance,
|
|
jsonPath: schemaPath,
|
|
suggestions: [
|
|
FixSuggestion(
|
|
description: 'Consider using composition with allOf',
|
|
codeExample:
|
|
'"allOf": [{ "\$ref": "#/components/schemas/BaseModel" }, { "type": "object", "properties": {...} }]',
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 验证安全方案
|
|
void _validateSecurityScheme(String name, ApiSecurityScheme scheme) {
|
|
final schemePath = 'components.securitySchemes["$name"]';
|
|
|
|
switch (scheme.type) {
|
|
case SecuritySchemeType.apiKey:
|
|
if (scheme.name == null || scheme.name!.isEmpty) {
|
|
_errorReporter.reportError(
|
|
id: 'MISSING_API_KEY_NAME',
|
|
title: 'Missing API Key Name',
|
|
description:
|
|
'API Key security scheme must specify a parameter name.',
|
|
severity: ErrorSeverity.error,
|
|
category: ErrorCategory.security,
|
|
jsonPath: '$schemePath.name',
|
|
suggestions: [
|
|
FixSuggestion(
|
|
description: 'Add name field for API key parameter',
|
|
codeExample: '"name": "X-API-Key"',
|
|
),
|
|
],
|
|
);
|
|
}
|
|
break;
|
|
|
|
case SecuritySchemeType.http:
|
|
if (scheme.scheme == null || scheme.scheme!.isEmpty) {
|
|
_errorReporter.reportError(
|
|
id: 'MISSING_HTTP_SCHEME',
|
|
title: 'Missing HTTP Scheme',
|
|
description:
|
|
'HTTP security scheme must specify a scheme (basic, bearer, etc.).',
|
|
severity: ErrorSeverity.error,
|
|
category: ErrorCategory.security,
|
|
jsonPath: '$schemePath.scheme',
|
|
suggestions: [
|
|
FixSuggestion(
|
|
description: 'Add scheme field',
|
|
codeExample: '"scheme": "bearer"',
|
|
),
|
|
],
|
|
);
|
|
}
|
|
break;
|
|
|
|
case SecuritySchemeType.oauth2:
|
|
if (scheme.flows == null) {
|
|
_errorReporter.reportError(
|
|
id: 'MISSING_OAUTH2_FLOWS',
|
|
title: 'Missing OAuth2 Flows',
|
|
description: 'OAuth2 security scheme must define flows.',
|
|
severity: ErrorSeverity.error,
|
|
category: ErrorCategory.security,
|
|
jsonPath: '$schemePath.flows',
|
|
suggestions: [
|
|
FixSuggestion(
|
|
description: 'Add flows configuration',
|
|
codeExample:
|
|
'"flows": { "authorizationCode": { "authorizationUrl": "...", "tokenUrl": "..." } }',
|
|
),
|
|
],
|
|
);
|
|
}
|
|
break;
|
|
|
|
case SecuritySchemeType.openIdConnect:
|
|
if (scheme.openIdConnectUrl == null ||
|
|
scheme.openIdConnectUrl!.isEmpty) {
|
|
_errorReporter.reportError(
|
|
id: 'MISSING_OPENID_URL',
|
|
title: 'Missing OpenID Connect URL',
|
|
description: 'OpenID Connect security scheme must specify a URL.',
|
|
severity: ErrorSeverity.error,
|
|
category: ErrorCategory.security,
|
|
jsonPath: '$schemePath.openIdConnectUrl',
|
|
suggestions: [
|
|
FixSuggestion(
|
|
description: 'Add OpenID Connect URL',
|
|
codeExample:
|
|
'"openIdConnectUrl": "https://example.com/.well-known/openid_configuration"',
|
|
),
|
|
],
|
|
);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
/// 验证安全配置
|
|
void _validateSecurity(SwaggerDocument document) {
|
|
// 这里可以添加安全配置的验证逻辑
|
|
}
|
|
|
|
/// 检查最佳实践
|
|
void _checkBestPractices(SwaggerDocument document) {
|
|
// 检查是否使用了标签
|
|
final hasTaggedOperations =
|
|
document.paths.values.any((path) => path.tags.isNotEmpty);
|
|
if (!hasTaggedOperations) {
|
|
_errorReporter.reportError(
|
|
id: 'NO_OPERATION_TAGS',
|
|
title: 'No Operation Tags',
|
|
description: 'Consider using tags to organize your API operations.',
|
|
severity: ErrorSeverity.info,
|
|
category: ErrorCategory.bestPractice,
|
|
jsonPath: 'paths',
|
|
suggestions: [
|
|
FixSuggestion(
|
|
description: 'Add tags to operations',
|
|
codeExample: '"tags": ["users"]',
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 提取路径参数
|
|
Set<String> _extractPathParameters(String path) {
|
|
final regex = RegExp(r'\{([^}]+)\}');
|
|
final matches = regex.allMatches(path);
|
|
return matches.map((match) => match.group(1)!).toSet();
|
|
}
|
|
|
|
/// 生成操作 ID
|
|
String _generateOperationId(String path, HttpMethod method) {
|
|
final pathParts = path
|
|
.split('/')
|
|
.where((part) => part.isNotEmpty && !part.startsWith('{'))
|
|
.toList();
|
|
final methodPrefix = method.value.toLowerCase();
|
|
|
|
if (pathParts.length >= 3) {
|
|
// 移除 api/v1 前缀
|
|
pathParts.removeRange(0, 2);
|
|
}
|
|
|
|
final nameParts = pathParts.map((part) => _toPascalCase(part)).join('');
|
|
return '$methodPrefix$nameParts';
|
|
}
|
|
|
|
/// 转换为 PascalCase
|
|
String _toPascalCase(String input) {
|
|
return input
|
|
.split('_')
|
|
.map((word) => word.isEmpty
|
|
? ''
|
|
: word[0].toUpperCase() + word.substring(1).toLowerCase())
|
|
.join('');
|
|
}
|
|
}
|