godcrm/god_frame/lib/shared/utils/api_client.dart
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

241 lines
8.2 KiB
Dart

import 'dart:async';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../../core/config.dart';
/// Secure storage provider.
final secureStorageProvider = Provider<FlutterSecureStorage>((ref) {
return const FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
);
});
/// In-memory cache for auth values to avoid repeated async reads.
class _AuthCache {
String? baseUrl;
String? token;
bool initialized = false;
}
final _authCache = _AuthCache();
/// Tracks whether a token refresh is already in progress to prevent
/// concurrent refresh requests from multiple 401 responses.
Completer<String?>? _refreshCompleter;
/// Dio HTTP client provider with auth interceptor and automatic token refresh.
final apiClientProvider = Provider<Dio>((ref) {
final storage = ref.watch(secureStorageProvider);
final dio = Dio(BaseOptions(
connectTimeout: AppConfig.connectTimeout,
receiveTimeout: AppConfig.receiveTimeout,
// Use contentType property (NOT headers map) so Dio can auto-override
// for FormData requests (multipart/form-data with boundary).
contentType: 'application/json',
headers: {
'Accept': 'application/json',
},
));
// Auth interceptor: attach JWT/API key to every request
dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) async {
try {
// Read from cache first, fallback to storage
if (!_authCache.initialized) {
_authCache.baseUrl = await storage.read(key: AppConfig.serverUrlKey);
_authCache.token = await storage.read(key: AppConfig.tokenKey);
_authCache.initialized = true;
// If no stored server URL, persist the default to prevent stale values
if (_authCache.baseUrl == null) {
_authCache.baseUrl = AppConfig.defaultBaseUrl;
await storage.write(key: AppConfig.serverUrlKey, value: AppConfig.defaultBaseUrl);
}
}
final baseUrl = _authCache.baseUrl ?? AppConfig.defaultBaseUrl;
options.baseUrl = '$baseUrl${AppConfig.apiPrefix}';
// Attach token — detect API key vs JWT
final token = _authCache.token ??
await storage.read(key: AppConfig.tokenKey);
if (token != null && token.isNotEmpty) {
if (token.startsWith('sk-')) {
// API key — use X-API-Key header
options.headers['X-API-Key'] = token;
} else {
// JWT — use Authorization Bearer
options.headers['Authorization'] = 'Bearer $token';
}
}
handler.next(options);
} catch (e) {
// Don't block requests due to storage errors
print('[API] Auth interceptor error: $e');
options.baseUrl = '${AppConfig.defaultBaseUrl}${AppConfig.apiPrefix}';
handler.next(options);
}
},
onError: (error, handler) async {
// On 401, attempt to refresh the access token using the stored refresh token.
// Skip refresh for auth endpoints themselves to avoid infinite loops.
if (error.response?.statusCode == 401) {
final requestPath = error.requestOptions.path;
// Don't try to refresh if the failing request IS the refresh/login/logout endpoint
if (requestPath.contains('/auth/refresh') ||
requestPath.contains('/auth/login') ||
requestPath.contains('/auth/logout')) {
_authCache.initialized = false;
return handler.next(error);
}
// Don't try to refresh for API key auth (sk-...) — those don't expire
final currentToken = _authCache.token;
if (currentToken != null && currentToken.startsWith('sk-')) {
return handler.next(error);
}
try {
// If a refresh is already in progress, wait for it
final String? newToken;
if (_refreshCompleter != null && !_refreshCompleter!.isCompleted) {
newToken = await _refreshCompleter!.future;
} else {
// Start a new refresh
_refreshCompleter = Completer<String?>();
newToken = await _performTokenRefresh(storage, dio);
_refreshCompleter!.complete(newToken);
}
if (newToken != null) {
// Retry the original request with the new token
final retryOptions = error.requestOptions;
retryOptions.headers['Authorization'] = 'Bearer $newToken';
final response = await dio.fetch(retryOptions);
return handler.resolve(response);
}
} catch (refreshError) {
print('[API] Token refresh error: $refreshError');
if (_refreshCompleter != null && !_refreshCompleter!.isCompleted) {
_refreshCompleter!.complete(null);
}
}
// Refresh failed — clear cache so next request re-reads storage
_authCache.initialized = false;
}
handler.next(error);
},
));
// Logging interceptor (shows URL and status)
dio.interceptors.add(LogInterceptor(
requestBody: false,
responseBody: false,
logPrint: (msg) => print('[API] $msg'),
));
return dio;
});
/// Perform the actual token refresh call.
/// Uses a raw Dio instance to avoid the interceptor re-triggering.
Future<String?> _performTokenRefresh(
FlutterSecureStorage storage, Dio parentDio) async {
try {
final refreshToken = await storage.read(key: AppConfig.refreshTokenKey);
if (refreshToken == null || refreshToken.isEmpty) {
print('[API] No refresh token available');
return null;
}
final baseUrl = _authCache.baseUrl ?? AppConfig.defaultBaseUrl;
// Use a separate Dio instance for the refresh call to avoid interceptor loops
final refreshDio = Dio(BaseOptions(
baseUrl: '$baseUrl${AppConfig.apiPrefix}',
connectTimeout: AppConfig.connectTimeout,
receiveTimeout: const Duration(seconds: 15),
contentType: 'application/json',
));
final response = await refreshDio.post(
AppConfig.refreshPath,
data: {'refreshToken': refreshToken},
);
if (response.statusCode == 200) {
final body = response.data;
final newAccessToken = body is Map
? (body['data']?['accessToken'] ?? body['accessToken'])
: null;
final newRefreshToken = body is Map
? (body['data']?['refreshToken'] ?? body['refreshToken'])
: null;
if (newAccessToken != null) {
await storage.write(key: AppConfig.tokenKey, value: newAccessToken);
_authCache.token = newAccessToken;
_authCache.initialized = true;
print('[API] Access token refreshed successfully');
}
if (newRefreshToken != null) {
await storage.write(
key: AppConfig.refreshTokenKey, value: newRefreshToken);
}
return newAccessToken;
}
print('[API] Token refresh returned status: ${response.statusCode}');
return null;
} catch (e) {
print('[API] Token refresh failed: $e');
return null;
}
}
/// Call this after login/logout to refresh the cached auth values.
void invalidateAuthCache() {
_authCache.initialized = false;
_authCache.token = null;
_authCache.baseUrl = null;
}
/// Update cached token (call after login).
void updateCachedToken(String? token) {
_authCache.token = token;
}
/// Update cached base URL (call after server URL change).
void updateCachedBaseUrl(String? url) {
_authCache.baseUrl = url;
}
/// Get the current base URL for resolving relative URLs (e.g. image attachments).
String getCurrentBaseUrl() => _authCache.baseUrl ?? AppConfig.defaultBaseUrl;
/// Get the cached auth token (for image loading headers etc).
String? getCachedToken() => _authCache.token;
/// API response wrapper.
class ApiResult<T> {
final T? data;
final String? error;
final int? statusCode;
const ApiResult({this.data, this.error, this.statusCode});
bool get isSuccess => data != null && error == null;
bool get isError => error != null;
factory ApiResult.success(T data) => ApiResult(data: data);
factory ApiResult.failure(String error, [int? statusCode]) =>
ApiResult(error: error, statusCode: statusCode);
}