2025-08-10 01:20:45 +05:30
|
|
|
import 'dart:async';
|
|
|
|
|
import 'dart:convert';
|
|
|
|
|
import 'package:flutter/services.dart';
|
2025-08-20 22:15:26 +05:30
|
|
|
import '../utils/debug_logger.dart';
|
2025-08-10 01:20:45 +05:30
|
|
|
|
|
|
|
|
/// 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 {
|
2025-08-20 22:15:26 +05:30
|
|
|
DebugLogger.validation('Loading OpenAPI specification...');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
|
|
|
|
// Try to load from assets first, then from file system as fallback
|
|
|
|
|
String openApiContent;
|
|
|
|
|
try {
|
|
|
|
|
openApiContent = await rootBundle.loadString('assets/openapi.json');
|
|
|
|
|
} catch (e) {
|
2025-08-20 22:15:26 +05:30
|
|
|
DebugLogger.warning(
|
|
|
|
|
'Could not load from assets, trying file system...',
|
2025-08-10 01:20:45 +05:30
|
|
|
);
|
|
|
|
|
// 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>;
|
|
|
|
|
|
2025-08-20 22:15:26 +05:30
|
|
|
DebugLogger.validation(
|
|
|
|
|
'Successfully loaded OpenAPI spec with ${_getPaths().length} paths',
|
2025-08-10 01:20:45 +05:30
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Pre-process and cache commonly used schemas
|
|
|
|
|
await _buildSchemaCache();
|
|
|
|
|
} catch (e) {
|
2025-08-20 22:15:26 +05:30
|
|
|
DebugLogger.error('Failed to load schemas', e);
|
2025-08-10 01:20:45 +05:30
|
|
|
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) {
|
2025-08-20 22:15:26 +05:30
|
|
|
DebugLogger.error(
|
|
|
|
|
'Error getting request schema for $method $endpoint',
|
|
|
|
|
e,
|
2025-08-10 01:20:45 +05:30
|
|
|
);
|
|
|
|
|
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) {
|
2025-08-20 22:15:26 +05:30
|
|
|
DebugLogger.error(
|
|
|
|
|
'Error getting response schema for $method $endpoint ($code)',
|
|
|
|
|
e,
|
2025-08-10 01:20:45 +05:30
|
|
|
);
|
|
|
|
|
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('#/')) {
|
2025-08-20 22:15:26 +05:30
|
|
|
DebugLogger.warning('External references not supported: $ref');
|
2025-08-10 01:20:45 +05:30
|
|
|
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 {
|
2025-08-20 22:15:26 +05:30
|
|
|
DebugLogger.warning('Could not resolve reference: $ref');
|
2025-08-10 01:20:45 +05:30
|
|
|
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++;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-20 22:15:26 +05:30
|
|
|
DebugLogger.validation('Pre-cached schemas for $cachedCount operations');
|
2025-08-10 01:20:45 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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();
|
|
|
|
|
}
|
|
|
|
|
}
|