Files
lis/mylis/lib/services/api_client.dart
T
2026-07-22 16:33:42 +07:00

291 lines
8.5 KiB
Dart

part of '../app/app.dart';
class ApiClient {
ApiClient({required this.baseUrl, this.token});
final String baseUrl;
String? token;
Uri _uri(String path, [Map<String, String>? query]) {
final normalizedBase = normalizeBaseUrl(baseUrl);
final base = Uri.parse(normalizedBase);
return base.replace(
path: '${base.path.replaceAll(RegExp(r'/$'), '')}/$path',
queryParameters: query,
);
}
Map<String, String> get _headers => {
'Accept': 'application/json',
'Content-Type': 'application/json',
if (token != null) 'Authorization': 'Bearer $token',
};
Future<Map<String, dynamic>> post(
String path,
Map<String, dynamic> body,
) async {
return _sendWithRenew(
path,
(client, uri) => client
.post(uri, headers: _headers, body: jsonEncode(body))
.timeout(const Duration(seconds: 15)),
);
}
Future<Map<String, dynamic>> get(
String path, [
Map<String, String>? query,
]) async {
return _sendWithRenew(
path,
(client, uri) => client
.get(uri, headers: _headers)
.timeout(const Duration(seconds: 15)),
query,
);
}
Future<Map<String, dynamic>> _sendWithRenew(
String path,
Future<http.Response> Function(http.Client client, Uri uri) send, [
Map<String, String>? query,
]) async {
final uri = _uri(path, query);
try {
return await _sendOnce(uri, send);
} on ApiException catch (error) {
if (!_canRenew(path, error)) {
rethrow;
}
final renewed = await _renewToken(uri);
if (!renewed) {
rethrow;
}
debugPrint('MyLIS session diperbarui, mengulang request: $uri');
return _sendOnce(uri, send);
}
}
Future<Map<String, dynamic>> _sendOnce(
Uri uri,
Future<http.Response> Function(http.Client client, Uri uri) send,
) async {
final client = _clientFor(uri);
try {
final response = await send(client, uri);
return _decode(response, uri);
} catch (error, stackTrace) {
throw _connectionException(uri, error, stackTrace);
} finally {
client.close();
}
}
bool _canRenew(String path, ApiException error) {
return token != null &&
error.statusCode == 401 &&
path != 'api/mobile/login' &&
!path.endsWith('/login');
}
Future<bool> _renewToken(Uri failedUri) async {
final credentials = await SessionStore.credentials();
if (credentials == null) {
debugPrint(
'MyLIS session expired untuk $failedUri; kredensial tersimpan tidak ada.',
);
return false;
}
final loginUri = _uri('api/mobile/login');
final client = _clientFor(loginUri);
try {
debugPrint(
'MyLIS session expired untuk $failedUri; mencoba renew token.',
);
final response = await client
.post(
loginUri,
headers: const {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: jsonEncode({
'username': credentials.username,
'password': credentials.password,
}),
)
.timeout(const Duration(seconds: 15));
final data = _decode(response, loginUri);
final token = data['token']?.toString() ?? '';
if (token.isEmpty) {
debugPrint(
'MyLIS renew token gagal: respons login tidak berisi token.',
);
return false;
}
this.token = token;
await SessionStore.saveLogin(
baseUrl: baseUrl,
token: token,
username: credentials.username,
password: credentials.password,
);
return true;
} on ApiException catch (error) {
debugPrint('MyLIS renew token gagal: ${error.message}');
return false;
} catch (error, stackTrace) {
debugPrint('MyLIS renew token gagal tidak terduga: $error');
debugPrintStack(stackTrace: stackTrace);
return false;
} finally {
client.close();
}
}
http.Client _clientFor(Uri uri) {
final client = HttpClient()
..badCertificateCallback = (certificate, host, port) {
debugPrint(
'MyLIS menerima sertifikat internal untuk $host:$port '
'issuer=${certificate.issuer}',
);
return true;
};
return IOClient(client);
}
Map<String, dynamic> _decode(http.Response response, Uri uri) {
final bodyPreview = response.body.length > 240
? '${response.body.substring(0, 240)}...'
: response.body;
if (response.statusCode < 200 || response.statusCode >= 300) {
Object? decoded;
if (_looksLikeJson(response)) {
try {
decoded = response.body.isEmpty
? <String, dynamic>{}
: jsonDecode(response.body);
} catch (_) {
decoded = null;
}
}
final message = decoded is Map ? decoded['message'] : null;
debugPrint(
'MyLIS API gagal: ${response.request?.method ?? '-'} $uri '
'HTTP ${response.statusCode}; body=$bodyPreview',
);
throw ApiException(
message?.toString() ??
'Server mengembalikan HTTP ${response.statusCode}. '
'URL: $uri. Kemungkinan request masuk ke virtual host/proxy yang salah. '
'Detail awal respons: $bodyPreview',
statusCode: response.statusCode,
);
}
final Object decoded;
try {
decoded = response.body.isEmpty
? <String, dynamic>{}
: jsonDecode(response.body);
} catch (error, stackTrace) {
debugPrint(
'MyLIS API decode gagal: ${response.request?.method ?? '-'} $uri '
'HTTP ${response.statusCode}; error=$error',
);
debugPrintStack(stackTrace: stackTrace);
throw ApiException(
'Respons server tidak bisa dibaca. '
'URL: $uri. Status HTTP: ${response.statusCode}.',
statusCode: response.statusCode,
);
}
if (response.statusCode >= 200 && response.statusCode < 300) {
final cleanDecoded = _decodeHtmlEntitiesIn(decoded);
return cleanDecoded is Map<String, dynamic>
? cleanDecoded
: {'data': cleanDecoded};
}
return {'data': _decodeHtmlEntitiesIn(decoded)};
}
bool _looksLikeJson(http.Response response) {
final contentType = response.headers['content-type']?.toLowerCase() ?? '';
final body = response.body.trimLeft();
return contentType.contains('json') ||
body.startsWith('{') ||
body.startsWith('[');
}
ApiException _connectionException(
Uri uri,
Object error,
StackTrace stackTrace,
) {
if (error is ApiException) {
return error;
}
final reason = _connectionReason(error);
debugPrint('MyLIS koneksi gagal: $uri; $reason; error=$error');
debugPrintStack(stackTrace: stackTrace);
return ApiException('Koneksi server gagal. URL: $uri. Penyebab: $reason');
}
String _connectionReason(Object error) {
if (error is TimeoutException) {
return 'timeout lebih dari 15 detik. Periksa jaringan, DNS lokal, atau server Laravel belum aktif.';
}
if (error is http.ClientException) {
final message = error.message.toLowerCase();
if (message.contains('certificate') ||
message.contains('handshake') ||
message.contains('tls')) {
return 'sertifikat HTTPS lokal belum dipercaya oleh device/simulator.';
}
if (message.contains('failed host lookup') ||
message.contains('nodename') ||
message.contains('name or service')) {
return 'host tidak ditemukan. Periksa URL atau DNS/hosts lokal.';
}
if (message.contains('connection refused')) {
return 'server menolak koneksi. Periksa apakah Laravel/web server aktif.';
}
if (message.contains('connection closed')) {
return 'koneksi ditutup oleh server.';
}
return 'client HTTP gagal: ${error.message}';
}
if (error is FormatException) {
return 'format URL atau respons tidak valid: ${error.message}';
}
return error.toString();
}
Object? _decodeHtmlEntitiesIn(Object? value) {
if (value is String) {
return decodeHtmlEntities(value);
}
if (value is List) {
return value.map(_decodeHtmlEntitiesIn).toList();
}
if (value is Map) {
return value.map(
(key, item) => MapEntry(key.toString(), _decodeHtmlEntitiesIn(item)),
);
}
return value;
}
}
class ApiException implements Exception {
ApiException(this.message, {this.statusCode});
final String message;
final int? statusCode;
}