refactor: removed unused api validator

This commit is contained in:
cogwheel0
2025-09-08 00:27:11 +05:30
parent 41f2739075
commit 3893e266f6
9 changed files with 3 additions and 24015 deletions

View File

@@ -147,7 +147,7 @@ See the dedicated documentation: [docs/localization.md](docs/localization.md)
| Conduit App | OpenWebUI | Notes |
| --- | --- | --- |
| 1.x | 0.3.x+ | Uses OpenAPI schema at `assets/openapi.json` |
| 1.x | 0.3.x+ | OpenAPI validation removed in 1.1+ (no bundled schema) |
## Docs

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,6 @@ import '../models/model.dart';
import '../models/conversation.dart';
import '../models/chat_message.dart';
import '../auth/api_auth_interceptor.dart';
import '../validation/validation_interceptor.dart';
import '../error/api_error_interceptor.dart';
// Tool-call details are parsed in the UI layer to render collapsible blocks
import 'persistent_streaming_service.dart';
@@ -66,16 +65,7 @@ class ApiService {
// 1. Auth interceptor (must be first to add auth headers)
_dio.interceptors.add(_authInterceptor);
// 2. Validation interceptor (validates requests/responses against OpenAPI schema)
// Disable for now to ensure parameters aren't being filtered
final validationInterceptor = ValidationInterceptor(
enableRequestValidation: false, // Disabled to preserve all parameters
enableResponseValidation: false, // Disabled for SSE streams
throwOnValidationError: false,
logValidationResults: kDebugMode,
);
// Comment out to disable completely
// _dio.interceptors.add(validationInterceptor);
// 2. Validation interceptor removed (no schema loading/logging)
// 3. Error handling interceptor (transforms errors to standardized format)
_dio.interceptors.add(
@@ -99,10 +89,7 @@ class ApiService {
// We now use custom interceptors with secure logging via DebugLogger
}
// Initialize validation interceptor asynchronously
validationInterceptor.initialize().catchError((error) {
// Handle validation initialization errors silently
});
// Validation interceptor fully removed
}
void updateAuthToken(String token) {

View File

@@ -1,416 +0,0 @@
import 'schema_registry.dart';
import 'validation_result.dart';
import 'field_mapper.dart';
import '../utils/debug_logger.dart';
/// Comprehensive API request and response validator
/// Validates against OpenAPI specification schemas
class ApiValidator {
static final ApiValidator _instance = ApiValidator._internal();
factory ApiValidator() => _instance;
ApiValidator._internal();
final SchemaRegistry _schemaRegistry = SchemaRegistry();
final FieldMapper _fieldMapper = FieldMapper();
bool _initialized = false;
bool get isInitialized => _initialized;
/// Initialize validator with OpenAPI schemas
Future<void> initialize() async {
if (_initialized) return;
try {
await _schemaRegistry.loadSchemas();
_initialized = true;
DebugLogger.validation('Successfully initialized with schemas');
} catch (e) {
DebugLogger.error('Failed to initialize', e);
// Continue without validation if schemas can't be loaded
}
}
/// Validate request payload before sending to API
ValidationResult validateRequest(
dynamic data,
String endpoint, {
String method = 'GET',
}) {
if (!_initialized) {
return ValidationResult.warning(
'Validator not initialized - skipping validation',
);
}
try {
final schema = _schemaRegistry.getRequestSchema(endpoint, method);
if (schema == null) {
return ValidationResult.warning(
'No schema found for $method $endpoint',
);
}
// Transform field names for API (camelCase -> snake_case)
final transformedData = _fieldMapper.toApiFormat(data);
// Validate against schema
return _validateAgainstSchema(transformedData, schema, 'request');
} catch (e) {
return ValidationResult.error('Request validation failed: $e');
}
}
/// Validate response payload after receiving from API
ValidationResult validateResponse(
dynamic data,
String endpoint, {
String method = 'GET',
int? statusCode,
}) {
if (!_initialized) {
return ValidationResult.warning(
'Validator not initialized - skipping validation',
);
}
try {
final schema = _schemaRegistry.getResponseSchema(
endpoint,
method,
statusCode,
);
if (schema == null) {
return ValidationResult.warning(
'No schema found for $method $endpoint response',
);
}
// Validate against schema first
final validationResult = _validateAgainstSchema(data, schema, 'response');
if (!validationResult.isValid) {
return validationResult;
}
// Transform field names from API (snake_case -> camelCase)
final transformedData = _fieldMapper.fromApiFormat(data);
return ValidationResult.success(
'Response validated successfully',
data: transformedData,
);
} catch (e) {
return ValidationResult.error('Response validation failed: $e');
}
}
/// Validate data against a specific schema
ValidationResult _validateAgainstSchema(
dynamic data,
Map<String, dynamic> schema,
String context,
) {
final errors = <String>[];
final warnings = <String>[];
try {
_validateValue(data, schema, '', errors, warnings);
if (errors.isNotEmpty) {
return ValidationResult.error(
'Schema validation failed for $context',
errors: errors,
warnings: warnings,
);
}
if (warnings.isNotEmpty) {
return ValidationResult.warning(
'Schema validation passed with warnings for $context',
warnings: warnings,
);
}
return ValidationResult.success('Schema validation passed for $context');
} catch (e) {
return ValidationResult.error('Schema validation error for $context: $e');
}
}
/// Recursively validate a value against schema
void _validateValue(
dynamic value,
Map<String, dynamic> schema,
String path,
List<String> errors,
List<String> warnings,
) {
final type = schema['type'] as String?;
final required = schema['required'] as List<dynamic>? ?? [];
// Handle null values
if (value == null) {
if (required.isNotEmpty && path.isNotEmpty) {
errors.add('Required field missing: $path');
}
return;
}
// Type validation
switch (type) {
case 'object':
_validateObject(value, schema, path, errors, warnings);
break;
case 'array':
_validateArray(value, schema, path, errors, warnings);
break;
case 'string':
_validateString(value, schema, path, errors, warnings);
break;
case 'number':
case 'integer':
_validateNumber(value, schema, path, errors, warnings);
break;
case 'boolean':
_validateBoolean(value, schema, path, errors, warnings);
break;
default:
// Unknown type - add warning but don't fail
warnings.add('Unknown schema type "$type" at $path');
}
}
void _validateObject(
dynamic value,
Map<String, dynamic> schema,
String path,
List<String> errors,
List<String> warnings,
) {
if (value is! Map) {
errors.add('Expected object at $path, got ${value.runtimeType}');
return;
}
final valueMap = value as Map<String, dynamic>;
final properties = schema['properties'] as Map<String, dynamic>? ?? {};
final required = (schema['required'] as List<dynamic>? ?? [])
.cast<String>();
// Check required fields
for (final requiredField in required) {
if (!valueMap.containsKey(requiredField)) {
errors.add(
'Required field missing: ${path.isEmpty ? '' : '$path.'}$requiredField',
);
}
}
// Validate each property
for (final entry in valueMap.entries) {
final fieldName = entry.key;
final fieldValue = entry.value;
final fieldPath = path.isEmpty ? fieldName : '$path.$fieldName';
if (properties.containsKey(fieldName)) {
_validateValue(
fieldValue,
properties[fieldName],
fieldPath,
errors,
warnings,
);
} else {
// Additional property - warn but don't error
warnings.add('Additional property found: $fieldPath');
}
}
}
void _validateArray(
dynamic value,
Map<String, dynamic> schema,
String path,
List<String> errors,
List<String> warnings,
) {
if (value is! List) {
errors.add('Expected array at $path, got ${value.runtimeType}');
return;
}
final array = value;
final items = schema['items'] as Map<String, dynamic>?;
final minItems = schema['minItems'] as int?;
final maxItems = schema['maxItems'] as int?;
// Validate array constraints
if (minItems != null && array.length < minItems) {
errors.add(
'Array at $path has ${array.length} items, minimum is $minItems',
);
}
if (maxItems != null && array.length > maxItems) {
errors.add(
'Array at $path has ${array.length} items, maximum is $maxItems',
);
}
// Validate each item
if (items != null) {
for (int i = 0; i < array.length; i++) {
_validateValue(array[i], items, '$path[$i]', errors, warnings);
}
}
}
void _validateString(
dynamic value,
Map<String, dynamic> schema,
String path,
List<String> errors,
List<String> warnings,
) {
if (value is! String) {
errors.add('Expected string at $path, got ${value.runtimeType}');
return;
}
final string = value;
final minLength = schema['minLength'] as int?;
final maxLength = schema['maxLength'] as int?;
final pattern = schema['pattern'] as String?;
final format = schema['format'] as String?;
if (minLength != null && string.length < minLength) {
errors.add(
'String at $path is ${string.length} chars, minimum is $minLength',
);
}
if (maxLength != null && string.length > maxLength) {
errors.add(
'String at $path is ${string.length} chars, maximum is $maxLength',
);
}
if (pattern != null) {
try {
final regex = RegExp(pattern);
if (!regex.hasMatch(string)) {
errors.add('String at $path does not match pattern: $pattern');
}
} catch (e) {
warnings.add('Invalid regex pattern at $path: $pattern');
}
}
// Validate common formats
if (format != null) {
_validateStringFormat(string, format, path, errors, warnings);
}
}
void _validateStringFormat(
String value,
String format,
String path,
List<String> errors,
List<String> warnings,
) {
switch (format) {
case 'email':
final emailRegex = RegExp(r'^[^\s@]+@[^\s@]+\.[^\s@]+$');
if (!emailRegex.hasMatch(value)) {
errors.add('Invalid email format at $path: $value');
}
break;
case 'uri':
case 'url':
try {
Uri.parse(value);
} catch (e) {
errors.add('Invalid URL format at $path: $value');
}
break;
case 'date':
try {
DateTime.parse(value);
} catch (e) {
errors.add('Invalid date format at $path: $value');
}
break;
case 'date-time':
try {
DateTime.parse(value);
} catch (e) {
errors.add('Invalid datetime format at $path: $value');
}
break;
case 'uuid':
final uuidRegex = RegExp(
r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$',
caseSensitive: false,
);
if (!uuidRegex.hasMatch(value)) {
errors.add('Invalid UUID format at $path: $value');
}
break;
default:
warnings.add('Unknown string format "$format" at $path');
}
}
void _validateNumber(
dynamic value,
Map<String, dynamic> schema,
String path,
List<String> errors,
List<String> warnings,
) {
if (value is! num) {
errors.add('Expected number at $path, got ${value.runtimeType}');
return;
}
final number = value;
final minimum = schema['minimum'] as num?;
final maximum = schema['maximum'] as num?;
final multipleOf = schema['multipleOf'] as num?;
if (minimum != null && number < minimum) {
errors.add('Number at $path is $number, minimum is $minimum');
}
if (maximum != null && number > maximum) {
errors.add('Number at $path is $number, maximum is $maximum');
}
if (multipleOf != null && number % multipleOf != 0) {
errors.add('Number at $path ($number) is not a multiple of $multipleOf');
}
}
void _validateBoolean(
dynamic value,
Map<String, dynamic> schema,
String path,
List<String> errors,
List<String> warnings,
) {
if (value is! bool) {
errors.add('Expected boolean at $path, got ${value.runtimeType}');
}
}
/// Transform and validate data for API consumption
Map<String, dynamic> transformForApi(Map<String, dynamic> data) {
return _fieldMapper.toApiFormat(data);
}
/// Transform and validate data from API response
Map<String, dynamic> transformFromApi(Map<String, dynamic> data) {
return _fieldMapper.fromApiFormat(data);
}
}

View File

@@ -1,279 +0,0 @@
import '../utils/debug_logger.dart';
/// Handles field name transformations between API and client formats
/// Converts between snake_case (API) and camelCase (client)
class FieldMapper {
static final FieldMapper _instance = FieldMapper._internal();
factory FieldMapper() => _instance;
FieldMapper._internal();
// Cache for converted field names to improve performance
final Map<String, String> _toCamelCaseCache = {};
final Map<String, String> _toSnakeCaseCache = {};
// Special field mappings that don't follow standard conversion rules
static const Map<String, String> _specialApiToClient = {
'created_at': 'createdAt',
'updated_at': 'updatedAt',
'user_id': 'userId',
'chat_id': 'chatId',
'message_id': 'messageId',
'session_id': 'sessionId',
'folder_id': 'folderId',
'share_id': 'shareId',
'model_id': 'modelId',
'tool_id': 'toolId',
'function_id': 'functionId',
'file_id': 'fileId',
'knowledge_base_id': 'knowledgeBaseId',
'channel_id': 'channelId',
'note_id': 'noteId',
'prompt_id': 'promptId',
'memory_id': 'memoryId',
'is_private': 'isPrivate',
'is_enabled': 'isEnabled',
'is_active': 'isActive',
'is_archived': 'isArchived',
'is_pinned': 'isPinned',
'api_key': 'apiKey',
'access_token': 'accessToken',
'refresh_token': 'refreshToken',
'content_type': 'contentType',
'file_size': 'fileSize',
'file_type': 'fileType',
'mime_type': 'mimeType',
// OpenWebUI chat message fields - keep in camelCase
'parentId': 'parentId',
'childrenIds': 'childrenIds',
'currentId': 'currentId',
'modelName': 'modelName',
'modelIdx': 'modelIdx',
};
static const Map<String, String> _specialClientToApi = {
'createdAt': 'created_at',
'updatedAt': 'updated_at',
'userId': 'user_id',
'chatId': 'chat_id',
'messageId': 'message_id',
'sessionId': 'session_id',
'folderId': 'folder_id',
'shareId': 'share_id',
'modelId': 'model_id',
'toolId': 'tool_id',
'functionId': 'function_id',
'fileId': 'file_id',
'knowledgeBaseId': 'knowledge_base_id',
'channelId': 'channel_id',
'noteId': 'note_id',
'promptId': 'prompt_id',
'memoryId': 'memory_id',
'isPrivate': 'is_private',
'isEnabled': 'is_enabled',
'isActive': 'is_active',
'isArchived': 'is_archived',
'isPinned': 'is_pinned',
'apiKey': 'api_key',
'accessToken': 'access_token',
'refreshToken': 'refresh_token',
'contentType': 'content_type',
'fileSize': 'file_size',
'fileType': 'file_type',
'mimeType': 'mime_type',
// OpenWebUI chat message fields - keep in camelCase
'parentId': 'parentId',
'childrenIds': 'childrenIds',
'currentId': 'currentId',
'modelName': 'modelName',
'modelIdx': 'modelIdx',
};
/// Transform data from client format (camelCase) to API format (snake_case)
dynamic toApiFormat(dynamic data) {
if (data == null) return null;
if (data is Map<String, dynamic>) {
return _transformMap(data, _toSnakeCase);
} else if (data is List) {
return data.map((item) => toApiFormat(item)).toList();
} else {
return data;
}
}
/// Transform data from API format (snake_case) to client format (camelCase)
dynamic fromApiFormat(dynamic data) {
if (data == null) return null;
if (data is Map<String, dynamic>) {
return _transformMap(data, _toCamelCase);
} else if (data is List) {
return data.map((item) => fromApiFormat(item)).toList();
} else {
return data;
}
}
/// Transform a map using the provided key transformation function
Map<String, dynamic> _transformMap(
Map<String, dynamic> map,
String Function(String) keyTransform,
) {
final transformed = <String, dynamic>{};
for (final entry in map.entries) {
final transformedKey = keyTransform(entry.key);
dynamic transformedValue = entry.value;
// Recursively transform nested objects and arrays
if (transformedValue is Map<String, dynamic>) {
transformedValue = _transformMap(transformedValue, keyTransform);
} else if (transformedValue is List) {
transformedValue = transformedValue.map((item) {
if (item is Map<String, dynamic>) {
return _transformMap(item, keyTransform);
}
return item;
}).toList();
}
transformed[transformedKey] = transformedValue;
}
return transformed;
}
/// Convert snake_case to camelCase
String _toCamelCase(String snakeCase) {
// Check cache first
if (_toCamelCaseCache.containsKey(snakeCase)) {
return _toCamelCaseCache[snakeCase]!;
}
// Check special mappings
if (_specialApiToClient.containsKey(snakeCase)) {
final result = _specialApiToClient[snakeCase]!;
_toCamelCaseCache[snakeCase] = result;
return result;
}
// Standard conversion
if (!snakeCase.contains('_')) {
_toCamelCaseCache[snakeCase] = snakeCase;
return snakeCase;
}
final words = snakeCase.split('_');
final result =
words.first + words.skip(1).map((word) => _capitalize(word)).join('');
_toCamelCaseCache[snakeCase] = result;
return result;
}
/// Convert camelCase to snake_case
String _toSnakeCase(String camelCase) {
// Check cache first
if (_toSnakeCaseCache.containsKey(camelCase)) {
return _toSnakeCaseCache[camelCase]!;
}
// Check special mappings
if (_specialClientToApi.containsKey(camelCase)) {
final result = _specialClientToApi[camelCase]!;
_toSnakeCaseCache[camelCase] = result;
return result;
}
// Standard conversion
final result = camelCase.replaceAllMapped(
RegExp(r'[A-Z]'),
(match) => '_${match.group(0)!.toLowerCase()}',
);
_toSnakeCaseCache[camelCase] = result;
return result;
}
/// Capitalize first letter of a word
String _capitalize(String word) {
if (word.isEmpty) return word;
return word[0].toUpperCase() + word.substring(1).toLowerCase();
}
/// Convert a single field name from snake_case to camelCase
String fieldToCamelCase(String snakeCase) {
return _toCamelCase(snakeCase);
}
/// Convert a single field name from camelCase to snake_case
String fieldToSnakeCase(String camelCase) {
return _toSnakeCase(camelCase);
}
/// Get all cached transformations for debugging
Map<String, dynamic> getCacheInfo() {
return {
'toCamelCacheSize': _toCamelCaseCache.length,
'toSnakeCacheSize': _toSnakeCaseCache.length,
'specialMappingsCount': _specialApiToClient.length,
};
}
/// Clear transformation caches
void clearCache() {
_toCamelCaseCache.clear();
_toSnakeCaseCache.clear();
DebugLogger.validation('Cleared transformation caches');
}
/// Add custom field mapping
void addCustomMapping(String apiField, String clientField) {
_specialApiToClient[apiField] = clientField;
_specialClientToApi[clientField] = apiField;
// Clear relevant cache entries
_toCamelCaseCache.remove(apiField);
_toSnakeCaseCache.remove(clientField);
DebugLogger.validation('Added custom mapping: $apiField <-> $clientField');
}
/// Validate that field transformations are reversible
bool validateTransformations() {
final errors = <String>[];
// Test special mappings
for (final entry in _specialApiToClient.entries) {
final apiField = entry.key;
final clientField = entry.value;
// Test API -> Client -> API
final backToApi = _toSnakeCase(clientField);
if (backToApi != apiField) {
errors.add(
'$apiField -> $clientField -> $backToApi (should be $apiField)',
);
}
// Test Client -> API -> Client
final backToClient = _toCamelCase(apiField);
if (backToClient != clientField) {
errors.add(
'$clientField -> $apiField -> $backToClient (should be $clientField)',
);
}
}
if (errors.isNotEmpty) {
DebugLogger.error('Transformation validation errors:');
for (final error in errors) {
DebugLogger.error(' $error');
}
return false;
}
DebugLogger.validation('All transformations validated successfully');
return true;
}
}

View File

@@ -1,368 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/services.dart';
import '../utils/debug_logger.dart';
/// Registry for OpenAPI schemas
/// Loads and provides access to request/response schemas for validation
class SchemaRegistry {
static final SchemaRegistry _instance = SchemaRegistry._internal();
factory SchemaRegistry() => _instance;
SchemaRegistry._internal();
Map<String, dynamic>? _openApiSpec;
final Map<String, Map<String, dynamic>> _requestSchemaCache = {};
final Map<String, Map<String, dynamic>> _responseSchemaCache = {};
bool get isLoaded => _openApiSpec != null;
/// Load schemas from OpenAPI specification
Future<void> loadSchemas() async {
try {
DebugLogger.validation('Loading OpenAPI specification...');
// Try to load from assets first, then from file system as fallback
String openApiContent;
try {
openApiContent = await rootBundle.loadString('assets/openapi.json');
} catch (e) {
DebugLogger.warning(
'Could not load from assets, trying file system...',
);
// Fallback - in a real app you might load from network or local file
throw Exception('OpenAPI specification not found in assets');
}
_openApiSpec = jsonDecode(openApiContent) as Map<String, dynamic>;
DebugLogger.validation(
'Successfully loaded OpenAPI spec with ${_getPaths().length} paths',
);
// Pre-process and cache commonly used schemas
await _buildSchemaCache();
} catch (e) {
DebugLogger.error('Failed to load schemas', e);
rethrow;
}
}
/// Get request schema for endpoint and method
Map<String, dynamic>? getRequestSchema(String endpoint, String method) {
if (!isLoaded) return null;
final cacheKey = '${method.toUpperCase()}:$endpoint:request';
if (_requestSchemaCache.containsKey(cacheKey)) {
return _requestSchemaCache[cacheKey];
}
try {
final pathItem = _findPathItem(endpoint);
if (pathItem == null) return null;
final operation = pathItem[method.toLowerCase()] as Map<String, dynamic>?;
if (operation == null) return null;
final requestBody = operation['requestBody'] as Map<String, dynamic>?;
if (requestBody == null) return null;
final content = requestBody['content'] as Map<String, dynamic>?;
if (content == null) return null;
// Try to find JSON content type
final jsonContent =
content['application/json'] as Map<String, dynamic>? ??
content.values.first as Map<String, dynamic>?;
if (jsonContent == null) return null;
final schema = _resolveSchema(
jsonContent['schema'] as Map<String, dynamic>?,
);
if (schema != null) {
_requestSchemaCache[cacheKey] = schema;
}
return schema;
} catch (e) {
DebugLogger.error(
'Error getting request schema for $method $endpoint',
e,
);
return null;
}
}
/// Get response schema for endpoint, method, and status code
Map<String, dynamic>? getResponseSchema(
String endpoint,
String method,
int? statusCode,
) {
if (!isLoaded) return null;
final code = statusCode?.toString() ?? '200';
final cacheKey = '${method.toUpperCase()}:$endpoint:response:$code';
if (_responseSchemaCache.containsKey(cacheKey)) {
return _responseSchemaCache[cacheKey];
}
try {
final pathItem = _findPathItem(endpoint);
if (pathItem == null) return null;
final operation = pathItem[method.toLowerCase()] as Map<String, dynamic>?;
if (operation == null) return null;
final responses = operation['responses'] as Map<String, dynamic>?;
if (responses == null) return null;
// Try to find the specific status code, or fall back to 'default' or '200'
final response =
responses[code] as Map<String, dynamic>? ??
responses['default'] as Map<String, dynamic>? ??
responses['200'] as Map<String, dynamic>?;
if (response == null) return null;
final content = response['content'] as Map<String, dynamic>?;
if (content == null) return null;
// Try to find JSON content type
final jsonContent =
content['application/json'] as Map<String, dynamic>? ??
content.values.first as Map<String, dynamic>?;
if (jsonContent == null) return null;
final schema = _resolveSchema(
jsonContent['schema'] as Map<String, dynamic>?,
);
if (schema != null) {
_responseSchemaCache[cacheKey] = schema;
}
return schema;
} catch (e) {
DebugLogger.error(
'Error getting response schema for $method $endpoint ($code)',
e,
);
return null;
}
}
/// Find path item that matches the given endpoint
Map<String, dynamic>? _findPathItem(String endpoint) {
final paths = _getPaths();
// Try exact match first
if (paths.containsKey(endpoint)) {
return paths[endpoint] as Map<String, dynamic>?;
}
// Try to find parameterized routes
for (final pathPattern in paths.keys) {
if (_matchesPathPattern(endpoint, pathPattern)) {
return paths[pathPattern] as Map<String, dynamic>?;
}
}
return null;
}
/// Check if endpoint matches a path pattern with parameters
bool _matchesPathPattern(String endpoint, String pattern) {
// Convert OpenAPI path parameters {id} to regex
final regexPattern = pattern.replaceAllMapped(
RegExp(r'\{([^}]+)\}'),
(match) => r'([^/]+)',
);
final regex = RegExp('^$regexPattern\$');
return regex.hasMatch(endpoint);
}
/// Get paths from OpenAPI spec
Map<String, dynamic> _getPaths() {
return _openApiSpec?['paths'] as Map<String, dynamic>? ?? {};
}
/// Resolve schema references ($ref)
Map<String, dynamic>? _resolveSchema(Map<String, dynamic>? schema) {
if (schema == null) return null;
// Handle $ref
final ref = schema['\$ref'] as String?;
if (ref != null) {
return _resolveReference(ref);
}
// Handle allOf, oneOf, anyOf
if (schema.containsKey('allOf')) {
return _mergeAllOfSchemas(schema['allOf'] as List);
}
if (schema.containsKey('oneOf') || schema.containsKey('anyOf')) {
// For now, just take the first schema in oneOf/anyOf
final schemas = (schema['oneOf'] ?? schema['anyOf']) as List;
if (schemas.isNotEmpty) {
return _resolveSchema(schemas.first as Map<String, dynamic>?);
}
}
// Recursively resolve nested schemas
final resolved = Map<String, dynamic>.from(schema);
if (resolved.containsKey('properties')) {
final properties = resolved['properties'] as Map<String, dynamic>;
final resolvedProperties = <String, dynamic>{};
for (final entry in properties.entries) {
resolvedProperties[entry.key] = _resolveSchema(
entry.value as Map<String, dynamic>?,
);
}
resolved['properties'] = resolvedProperties;
}
if (resolved.containsKey('items')) {
resolved['items'] = _resolveSchema(
resolved['items'] as Map<String, dynamic>?,
);
}
return resolved;
}
/// Resolve $ref reference
Map<String, dynamic>? _resolveReference(String ref) {
if (!ref.startsWith('#/')) {
DebugLogger.warning('External references not supported: $ref');
return null;
}
final path = ref.substring(2).split('/');
dynamic current = _openApiSpec;
for (final segment in path) {
if (current is Map<String, dynamic> && current.containsKey(segment)) {
current = current[segment];
} else {
DebugLogger.warning('Could not resolve reference: $ref');
return null;
}
}
return _resolveSchema(current as Map<String, dynamic>?);
}
/// Merge allOf schemas
Map<String, dynamic> _mergeAllOfSchemas(List schemas) {
final merged = <String, dynamic>{};
final mergedProperties = <String, dynamic>{};
final mergedRequired = <String>[];
for (final schema in schemas) {
final resolvedSchema = _resolveSchema(schema as Map<String, dynamic>?);
if (resolvedSchema == null) continue;
// Merge top-level properties
merged.addAll(resolvedSchema);
// Merge properties
if (resolvedSchema.containsKey('properties')) {
mergedProperties.addAll(
resolvedSchema['properties'] as Map<String, dynamic>,
);
}
// Merge required fields
if (resolvedSchema.containsKey('required')) {
mergedRequired.addAll(
(resolvedSchema['required'] as List).cast<String>(),
);
}
}
if (mergedProperties.isNotEmpty) {
merged['properties'] = mergedProperties;
}
if (mergedRequired.isNotEmpty) {
merged['required'] = mergedRequired;
}
return merged;
}
/// Pre-build cache of commonly used schemas
Future<void> _buildSchemaCache() async {
if (!isLoaded) return;
final paths = _getPaths();
int cachedCount = 0;
for (final pathEntry in paths.entries) {
final path = pathEntry.key;
final pathItem = pathEntry.value as Map<String, dynamic>;
for (final method in ['get', 'post', 'put', 'delete', 'patch']) {
if (pathItem.containsKey(method)) {
// Cache request schema
getRequestSchema(path, method);
// Cache common response schemas
getResponseSchema(path, method, 200);
getResponseSchema(path, method, 201);
getResponseSchema(path, method, 400);
getResponseSchema(path, method, 401);
getResponseSchema(path, method, 403);
getResponseSchema(path, method, 404);
getResponseSchema(path, method, 422);
getResponseSchema(path, method, 500);
cachedCount++;
}
}
}
DebugLogger.validation('Pre-cached schemas for $cachedCount operations');
}
/// Get all available endpoints
List<String> getAvailableEndpoints() {
if (!isLoaded) return [];
return _getPaths().keys.toList();
}
/// Get available methods for an endpoint
List<String> getAvailableMethods(String endpoint) {
final pathItem = _findPathItem(endpoint);
if (pathItem == null) return [];
return pathItem.keys
.where(
(key) => [
'get',
'post',
'put',
'delete',
'patch',
'head',
'options',
].contains(key),
)
.toList();
}
/// Clear all caches
void clearCache() {
_requestSchemaCache.clear();
_responseSchemaCache.clear();
}
}

View File

@@ -1,217 +0,0 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'api_validator.dart';
import 'validation_result.dart';
import '../utils/debug_logger.dart';
/// Dio interceptor for automatic API validation
/// Validates requests and responses against OpenAPI schemas
class ValidationInterceptor extends Interceptor {
final ApiValidator _validator = ApiValidator();
final bool enableRequestValidation;
final bool enableResponseValidation;
final bool throwOnValidationError;
final bool logValidationResults;
ValidationInterceptor({
this.enableRequestValidation = true,
this.enableResponseValidation = true,
this.throwOnValidationError = false,
this.logValidationResults = true,
});
/// Initialize the validator
Future<void> initialize() async {
await _validator.initialize();
}
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
if (enableRequestValidation && options.data != null) {
try {
final result = _validator.validateRequest(
options.data,
options.path,
method: options.method,
);
if (logValidationResults) {
_logValidationResult(result, 'REQUEST', options.path, options.method);
}
if (!result.isValid && throwOnValidationError) {
throw ValidationException(result);
}
// Transform data if validation succeeded
// Temporarily disabled to preserve background_tasks and session_id parameters
// if (result.isValid && options.data is Map<String, dynamic>) {
// options.data = _validator.transformForApi(
// options.data as Map<String, dynamic>,
// );
// }
} catch (e) {
if (e is ValidationException) {
handler.reject(
DioException(
requestOptions: options,
error: e,
type: DioExceptionType.unknown,
message: 'Request validation failed: ${e.result.message}',
),
);
return;
} else {
DebugLogger.error('Request validation error', e);
}
}
}
handler.next(options);
}
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
if (enableResponseValidation && response.data != null) {
try {
final result = _validator.validateResponse(
response.data,
response.requestOptions.path,
method: response.requestOptions.method,
statusCode: response.statusCode,
);
if (logValidationResults) {
_logValidationResult(
result,
'RESPONSE',
response.requestOptions.path,
response.requestOptions.method,
statusCode: response.statusCode,
);
}
if (!result.isValid && throwOnValidationError) {
throw ValidationException(result);
}
// Transform data if validation succeeded and data is available
if (result.isValid && result.data != null) {
response.data = result.data;
} else if (result.isValid && response.data is Map<String, dynamic>) {
response.data = _validator.transformFromApi(
response.data as Map<String, dynamic>,
);
}
// Store validation result in response for debugging
if (kDebugMode) {
response.extra['validationResult'] = result;
}
} catch (e) {
if (e is ValidationException) {
handler.reject(
DioException(
requestOptions: response.requestOptions,
response: response,
error: e,
type: DioExceptionType.unknown,
message: 'Response validation failed: ${e.result.message}',
),
);
return;
} else {
DebugLogger.error('Response validation error', e);
}
}
}
handler.next(response);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
// Try to validate error responses too
if (enableResponseValidation && err.response?.data != null) {
try {
final result = _validator.validateResponse(
err.response!.data,
err.requestOptions.path,
method: err.requestOptions.method,
statusCode: err.response!.statusCode,
);
if (logValidationResults) {
_logValidationResult(
result,
'ERROR_RESPONSE',
err.requestOptions.path,
err.requestOptions.method,
statusCode: err.response!.statusCode,
);
}
// Transform error response data
if (result.isValid && result.data != null) {
err.response!.data = result.data;
} else if (result.isValid &&
err.response!.data is Map<String, dynamic>) {
err.response!.data = _validator.transformFromApi(
err.response!.data as Map<String, dynamic>,
);
}
// Store validation result for debugging
if (kDebugMode) {
err.response!.extra['validationResult'] = result;
}
} catch (e) {
DebugLogger.error('Error response validation failed', e);
}
}
handler.next(err);
}
/// Log validation results in a structured format
void _logValidationResult(
ValidationResult result,
String type,
String path,
String method, {
int? statusCode,
}) {
if (!logValidationResults) return;
final statusText = statusCode != null ? ' ($statusCode)' : '';
final icon = result.isValid ? '' : '';
DebugLogger.validation(
'$icon Validation $type: ${method.toUpperCase()} $path$statusText - ${result.status.name}',
);
if (result.hasErrors) {
DebugLogger.error(' Errors: ${result.errors.join(', ')}');
}
if (result.hasWarnings) {
DebugLogger.warning(' Warnings: ${result.warnings.join(', ')}');
}
if (result.message.isNotEmpty &&
result.status != ValidationStatus.success) {
DebugLogger.info(' Message: ${result.message}');
}
}
/// Get validation statistics
Map<String, dynamic> getStats() {
return {
'requestValidationEnabled': enableRequestValidation,
'responseValidationEnabled': enableResponseValidation,
'throwOnError': throwOnValidationError,
'loggingEnabled': logValidationResults,
'validatorInitialized': _validator.isInitialized,
};
}
}

View File

@@ -1,100 +0,0 @@
/// Result of API validation operations
class ValidationResult {
const ValidationResult._({
required this.isValid,
required this.status,
required this.message,
this.errors = const [],
this.warnings = const [],
this.data,
});
const ValidationResult.success(
String message, {
dynamic data,
List<String> warnings = const [],
}) : this._(
isValid: true,
status: ValidationStatus.success,
message: message,
warnings: warnings,
data: data,
);
const ValidationResult.warning(
String message, {
List<String> warnings = const [],
dynamic data,
}) : this._(
isValid: true,
status: ValidationStatus.warning,
message: message,
warnings: warnings,
data: data,
);
const ValidationResult.error(
String message, {
List<String> errors = const [],
List<String> warnings = const [],
}) : this._(
isValid: false,
status: ValidationStatus.error,
message: message,
errors: errors,
warnings: warnings,
);
final bool isValid;
final ValidationStatus status;
final String message;
final List<String> errors;
final List<String> warnings;
final dynamic data;
bool get hasWarnings => warnings.isNotEmpty;
bool get hasErrors => errors.isNotEmpty;
@override
String toString() {
final buffer = StringBuffer();
buffer.write('ValidationResult(');
buffer.write('status: $status, ');
buffer.write('message: $message');
if (hasErrors) {
buffer.write(', errors: ${errors.length}');
}
if (hasWarnings) {
buffer.write(', warnings: ${warnings.length}');
}
buffer.write(')');
return buffer.toString();
}
/// Convert to a detailed map for logging/debugging
Map<String, dynamic> toMap() {
return {
'isValid': isValid,
'status': status.name,
'message': message,
'errors': errors,
'warnings': warnings,
'hasData': data != null,
};
}
}
enum ValidationStatus { success, warning, error }
/// Exception thrown when validation fails critically
class ValidationException implements Exception {
const ValidationException(this.result);
final ValidationResult result;
@override
String toString() => 'ValidationException: ${result.message}';
}

View File

@@ -80,7 +80,6 @@ flutter:
assets:
- assets/icons/
- assets/openapi.json
flutter_native_splash:
# Background color (Conduit dark theme)