You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

572 lines
24 KiB
Dart

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

// ignore_for_file: avoid_catches_without_on_clauses
import 'dart:developer';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:dio/dio.dart';
import 'api_response.dart';
import 'http_method.dart';
import 'token_manager.dart';
// ─── Custom exception ──────────────────────────────────────────────────────
/// Thrown **synchronously** when any [ApiClient] call method is invoked before
/// [ApiClient.initialize] has been called.
///
/// This is an exception (not a silent [ApiFailure]) because it represents a
/// **programming error** — the developer forgot to call [ApiClient.initialize]
/// in `main()`. It should crash loudly in development so it is never shipped.
final class ApiClientNotInitializedException implements Exception {
const ApiClientNotInitializedException();
@override
String toString() =>
'ApiClientNotInitializedException: '
'ApiClient.initialize() must be called before making any API calls. '
'Add `ApiClient.initialize(baseUrl: …)` to your main() function.';
}
// ─── ApiClient ─────────────────────────────────────────────────────────────
/// A thin, zero-boilerplate HTTP wrapper around [Dio] for Flutter apps.
///
/// ### Quick start
/// ```dart
/// // 1. Once in main():
/// ApiClient.initialize(baseUrl: 'https://api.example.com');
///
/// // 2. Make a call:
/// final result = await ApiClient.get<User>(
/// url: '/users/me',
/// shouldKeepToken: true,
/// fromJson: (j) => User.fromJson(j as Map<String, dynamic>),
/// );
/// switch (result) {
/// case ApiSuccess(:final data): print(data.name);
/// case ApiFailure(:final message): print('Error: $message');
/// }
///
/// // 3. Cancel all in-flight requests (e.g. from a BLoC's close()):
/// ApiClient.cancel();
/// ```
///
/// ---
/// ### Design decisions
///
/// **Named methods vs. a single `call(HttpMethod, …)`**
/// Separate named methods (`get`, `post`, `put`, `patch`, `delete`) are
/// preferred because:
/// 1. Call sites are instantly readable — `ApiClient.post(url: …)` vs
/// `ApiClient.call(HttpMethod.post, url: …)`.
/// 2. Each method can omit inapplicable parameters (e.g. no `body` on `get`).
/// 3. IDE auto-complete surfaces the right signature without guessing.
/// All named methods delegate to the private [_call] dispatcher so there is
/// zero logic duplication.
///
/// **URL resolution**
/// If [url] already starts with `http://` or `https://`, Dio uses it **as-is**
/// (absolute URL) — the configured [BaseOptions.baseUrl] is _not_ prepended.
/// For relative paths, Dio concatenates `baseUrl + url` automatically.
/// This matches browser / fetch / axios semantics and prevents accidentally
/// double-prepending the base when the caller passes a full URL for a
/// third-party endpoint.
class ApiClient {
ApiClient._();
static Dio? _dio;
static bool _isInitialized = false;
static bool Function(Response<dynamic>)? _globalIsSuccess;
/// Shared cancel token for all in-flight requests.
///
/// After [cancel] is invoked, a fresh [CancelToken] is created immediately
/// so subsequent calls are never pre-cancelled — the client stays usable.
///
/// ### Extension point for per-request cancellation
/// To layer per-request cancellation on in the future, accept an optional
/// `String? requestTag` in every call method and maintain a
/// `static final Map<String, CancelToken> _taggedTokens = {}` registry
/// inside [_call]. No structural rewrite of the existing code is needed.
static CancelToken _cancelToken = CancelToken();
// ── Initialization ────────────────────────────────────────────────────────
/// Configure the global [Dio] instance. **Call exactly once at app startup,
/// before any API call is made.**
///
/// Calling [initialize] again replaces the existing instance (useful in
/// tests that need different base URLs or interceptors).
///
/// Parameters:
/// - [baseUrl] — prepended to every relative URL.
/// - [headers] — merged on top of the default `Content-Type` /
/// `Accept` headers for every request.
/// - [connectTimeout] — how long to wait for a connection; default 30 s.
/// - [receiveTimeout] — how long to wait for a full response; default 30 s.
/// - [isSuccess] — global predicate that decides whether a [Response]
/// is successful. Falls back to HTTP 200299 when
/// `null`.
/// - [interceptors] — extra [Interceptor]s (logging, auth-refresh, etc.)
/// added to the [Dio] instance.
static void initialize({
required String baseUrl,
Map<String, dynamic>? headers,
Duration connectTimeout = const Duration(seconds: 30),
Duration receiveTimeout = const Duration(seconds: 30),
bool Function(Response<dynamic>)? isSuccess,
List<Interceptor> interceptors = const [],
}) {
_globalIsSuccess = isSuccess;
_isInitialized = false; // guard against partially-constructed state
_dio = Dio(
BaseOptions(
baseUrl: baseUrl,
connectTimeout: connectTimeout,
receiveTimeout: receiveTimeout,
headers: <String, dynamic>{
'Content-Type': 'application/json',
'Accept': 'application/json',
...?headers,
},
),
);
if (interceptors.isNotEmpty) {
_dio!.interceptors.addAll(interceptors);
}
_isInitialized = true;
}
// ── Cancellation ─────────────────────────────────────────────────────────
/// Cancel **all** currently in-flight requests that were made through
/// [ApiClient].
///
/// A fresh [CancelToken] is created immediately afterward so new requests
/// are never affected — the client remains fully usable after cancellation.
///
/// Cancelled requests resolve as [ApiFailure] with `code: -1` and the
/// provided [reason] as their [ApiFailure.message].
///
/// Typical usage — inside a BLoC's `close()`:
/// ```dart
/// @override
/// Future<void> close() {
/// ApiClient.cancel();
/// return super.close();
/// }
/// ```
static void cancel({String reason = 'Request was cancelled'}) {
_cancelToken.cancel(reason);
// Regenerate immediately so subsequent requests are never pre-cancelled.
_cancelToken = CancelToken();
}
// ── Public call methods ───────────────────────────────────────────────────
/// Perform an HTTP **GET** request.
///
/// - [url] — absolute (`https://…`) or relative path resolved
/// against [baseUrl].
/// - [queryParams] — appended as URL query parameters.
/// - [headers] — per-call headers merged on top of defaults.
/// - [shouldKeepToken] — when `true`, the stored [TokenManager] token is
/// attached as `Authorization: Bearer <token>`, and any
/// `Authorization` header returned in the response is
/// persisted back to [TokenManager].
/// - [isSuccess] — per-call predicate that overrides the global default
/// set in [initialize] for this single call.
/// - [fromJson] — converts the successful response body to [T].
///
/// Returns [ApiSuccess<T>] on success or [ApiFailure<T>] on any error.
/// **Never throws** (except [ApiClientNotInitializedException] if
/// [initialize] was never called — this is a programming error).
static Future<ApiResponse<T>> get<T>({
required String url,
Map<String, dynamic>? queryParams,
Map<String, dynamic>? headers,
bool shouldKeepToken = false,
bool Function(Response<dynamic>)? isSuccess,
required T Function(dynamic json) fromJson,
}) =>
_call(
method: HttpMethod.get,
url: url,
queryParams: queryParams,
headers: headers,
shouldKeepToken: shouldKeepToken,
isSuccess: isSuccess,
fromJson: fromJson,
);
/// Perform an HTTP **POST** request.
///
/// - [body] — request body; Dio serialises `Map` / `List` to JSON
/// automatically. Pass a [FormData] for multipart uploads.
static Future<ApiResponse<T>> post<T>({
required String url,
dynamic body,
Map<String, dynamic>? queryParams,
Map<String, dynamic>? headers,
bool shouldKeepToken = false,
bool Function(Response<dynamic>)? isSuccess,
required T Function(dynamic json) fromJson,
}) =>
_call(
method: HttpMethod.post,
url: url,
body: body,
queryParams: queryParams,
headers: headers,
shouldKeepToken: shouldKeepToken,
isSuccess: isSuccess,
fromJson: fromJson,
);
/// Perform an HTTP **PUT** request.
///
/// - [body] — request body (serialised to JSON by Dio).
static Future<ApiResponse<T>> put<T>({
required String url,
dynamic body,
Map<String, dynamic>? queryParams,
Map<String, dynamic>? headers,
bool shouldKeepToken = false,
bool Function(Response<dynamic>)? isSuccess,
required T Function(dynamic json) fromJson,
}) =>
_call(
method: HttpMethod.put,
url: url,
body: body,
queryParams: queryParams,
headers: headers,
shouldKeepToken: shouldKeepToken,
isSuccess: isSuccess,
fromJson: fromJson,
);
/// Perform an HTTP **PATCH** request.
///
/// - [body] — request body (serialised to JSON by Dio).
static Future<ApiResponse<T>> patch<T>({
required String url,
dynamic body,
Map<String, dynamic>? queryParams,
Map<String, dynamic>? headers,
bool shouldKeepToken = false,
bool Function(Response<dynamic>)? isSuccess,
required T Function(dynamic json) fromJson,
}) =>
_call(
method: HttpMethod.patch,
url: url,
body: body,
queryParams: queryParams,
headers: headers,
shouldKeepToken: shouldKeepToken,
isSuccess: isSuccess,
fromJson: fromJson,
);
/// Perform an HTTP **DELETE** request.
///
/// - [body] — optional request body (serialised to JSON by Dio).
static Future<ApiResponse<T>> delete<T>({
required String url,
dynamic body,
Map<String, dynamic>? queryParams,
Map<String, dynamic>? headers,
bool shouldKeepToken = false,
bool Function(Response<dynamic>)? isSuccess,
required T Function(dynamic json) fromJson,
}) =>
_call(
method: HttpMethod.delete,
url: url,
body: body,
queryParams: queryParams,
headers: headers,
shouldKeepToken: shouldKeepToken,
isSuccess: isSuccess,
fromJson: fromJson,
);
// ── Core dispatcher ───────────────────────────────────────────────────────
/// Unified internal dispatcher — every public method delegates here.
///
/// Execution pipeline (in order):
/// 1. **Guard** — throw [ApiClientNotInitializedException] if [initialize]
/// was never called.
/// 2. **Connectivity** — return [ApiFailure] immediately if offline.
/// 3. **Token injection** — if [shouldKeepToken] is `true` and
/// [TokenManager.hasToken], add `Authorization: Bearer …` to headers.
/// 4. **Execute** — fire the Dio call with the shared [_cancelToken].
/// 5. **Token extraction** — if [shouldKeepToken] is `true` and the response
/// contains an `Authorization` header, persist the new token.
/// 6. **Success predicate** — per-call → global → HTTP 200299 fallback.
/// 7. **Parse** — call [fromJson] → wrap in [ApiSuccess].
/// 8. **Error mapping** — catch [DioException] and any other exception and
/// return an appropriate [ApiFailure].
static Future<ApiResponse<T>> _call<T>({
required HttpMethod method,
required String url,
dynamic body,
Map<String, dynamic>? queryParams,
Map<String, dynamic>? headers,
required bool shouldKeepToken,
bool Function(Response<dynamic>)? isSuccess,
required T Function(dynamic json) fromJson,
}) async {
// ── 1. Guard ─────────────────────────────────────────────────────────────
if (!_isInitialized || _dio == null) {
throw const ApiClientNotInitializedException();
}
// ── 2. Connectivity check ────────────────────────────────────────────────
// We wrap this in its own try/catch: if the connectivity plugin itself
// throws (e.g. on simulator/unknown platform), we proceed and let the
// actual network error surface naturally rather than blocking the call.
try {
final results = await Connectivity().checkConnectivity();
final isOffline = results.isEmpty ||
results.every((r) => r == ConnectivityResult.none);
if (isOffline) {
return ApiFailure<T>(
message:
'No internet connection. Please check your network settings.',
);
}
} catch (_) {
// Connectivity check failed — proceed optimistically.
}
// ── 3. Build headers + optional Bearer token ─────────────────────────────
final mergedHeaders = <String, dynamic>{...?headers};
if (shouldKeepToken && TokenManager.hasToken) {
mergedHeaders['Authorization'] = 'Bearer ${TokenManager.getToken()}';
}
final options = Options(
headers: mergedHeaders.isNotEmpty ? mergedHeaders : null,
);
// ── 4. Execute Dio call ──────────────────────────────────────────────────
try {
late final Response<dynamic> response;
switch (method) {
case HttpMethod.get:
response = await _dio!.get(
url,
queryParameters: queryParams,
options: options,
cancelToken: _cancelToken,
);
case HttpMethod.post:
response = await _dio!.post(
url,
data: body,
queryParameters: queryParams,
options: options,
cancelToken: _cancelToken,
);
case HttpMethod.put:
response = await _dio!.put(
url,
data: body,
queryParameters: queryParams,
options: options,
cancelToken: _cancelToken,
);
case HttpMethod.patch:
response = await _dio!.patch(
url,
data: body,
queryParameters: queryParams,
options: options,
cancelToken: _cancelToken,
);
case HttpMethod.delete:
response = await _dio!.delete(
url,
data: body,
queryParameters: queryParams,
options: options,
cancelToken: _cancelToken,
);
}
log("+++++++++++++++++++++++++++");
log("$body");
log("+++++++++++++++++++++++++++");
log("$response");
log("+++++++++++++++++++++++++++");
// ── 5. Token extraction from response ───────────────────────────────
if (shouldKeepToken) {
// Dio lowercases header names so check both casings for safety.
final raw = response.headers.value('authorization') ??
response.headers.value('Authorization');
if (raw != null && raw.isNotEmpty) {
final token =
raw.startsWith('Bearer ') ? raw.substring(7) : raw;
TokenManager.setToken(token);
}
}
// ── 6. Success predicate ────────────────────────────────────────────
// Priority order: per-call → global → HTTP 200299 fallback.
final succeeded = isSuccess != null
? isSuccess(response)
: _globalIsSuccess != null
? _globalIsSuccess!(response)
: (response.statusCode != null &&
response.statusCode! >= 200 &&
response.statusCode! < 300);
if (!succeeded) {
return ApiFailure<T>(
code: response.statusCode,
message: _extractErrorMessage(response.data) ??
'Request failed with status ${response.statusCode}',
);
}
// ── 7. Parse ────────────────────────────────────────────────────────
try {
return ApiSuccess<T>(fromJson(response.data));
} catch (e) {
return ApiFailure<T>(
code: response.statusCode,
message: 'Response parsing failed: $e',
);
}
} on DioException catch (e) {
return _mapDioException<T>(e);
} catch (e) {
return ApiFailure<T>(message: 'Unexpected error: $e');
}
}
// ── Helpers ───────────────────────────────────────────────────────────────
/// Map a [DioException] to the most descriptive [ApiFailure] available.
///
/// Cancellations are identified by `code: -1` so callers can distinguish
/// them from real HTTP failures without string-matching on [ApiFailure.message].
static ApiFailure<T> _mapDioException<T>(DioException e) {
// Cancelled request — distinct code so BLoCs can decide not to show
// an error dialog when the user navigated away.
if (e.type == DioExceptionType.cancel) {
return ApiFailure<T>(
code: -1,
message: e.message ?? 'Request was cancelled',
);
}
final code = e.response?.statusCode;
switch (e.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
return ApiFailure<T>(
code: code,
message: 'Connection timed out. Please try again.',
);
case DioExceptionType.connectionError:
return ApiFailure<T>(
code: code,
message: 'Unable to reach the server. Check your connection.',
);
default:
// Try to pull a message out of the error response body first.
final serverMsg =
e.response != null ? _extractErrorMessage(e.response!.data) : null;
return ApiFailure<T>(
code: code,
message: serverMsg ?? e.message ?? 'An unknown error occurred.',
);
}
}
/// Attempt to pull a human-readable error string from a response body.
///
/// Handles the three most common API error patterns:
/// ```json
/// { "message": "…" }
/// { "error": "…" }
/// { "error": { "message": "…" } }
/// ```
static String? _extractErrorMessage(dynamic data) {
if (data is! Map<String, dynamic>) return null;
if (data['message'] is String) return data['message'] as String;
if (data['error'] is String) return data['error'] as String;
if (data['error'] is Map) {
final inner = data['error'] as Map;
if (inner['message'] is String) return inner['message'] as String;
}
return null;
}
}
// ─── Usage example ─────────────────────────────────────────────────────────
//
// ┌── main.dart ─────────────────────────────────────────────────────────────┐
// │ │
// │ import 'package:your_app/core/network/api_client.dart'; │
// │ import 'package:dio/dio.dart'; │
// │ import 'package:flutter/material.dart'; │
// │ │
// │ void main() { │
// │ // ① Configure once before runApp(). │
// │ ApiClient.initialize( │
// │ baseUrl: 'https://api.example.com', │
// │ connectTimeout: const Duration(seconds: 15), │
// │ receiveTimeout: const Duration(seconds: 15), │
// │ headers: {'X-App-Version': '1.0.0'}, │
// │ // Global rule: treat only status 200 as success. │
// │ isSuccess: (response) => response.statusCode == 200, │
// │ interceptors: [LogInterceptor(requestBody: true)], │
// │ ); │
// │ runApp(const MyApp()); │
// │ } │
// └──────────────────────────────────────────────────────────────────────────┘
//
// ┌── GET with custom isSuccess predicate + shouldKeepToken ─────────────────┐
// │ │
// │ Future<void> fetchProfile() async { │
// │ final result = await ApiClient.get<User>( │
// │ url: '/users/me', // relative → Dio prepends baseUrl │
// │ shouldKeepToken: true, // adds Authorization: Bearer <token> │
// │ // Per-call override: accept both 200 and 201 for this endpoint. │
// │ isSuccess: (r) => r.statusCode == 200 || r.statusCode == 201, │
// │ fromJson: (j) => User.fromJson(j as Map<String, dynamic>), │
// │ ); │
// │ │
// │ switch (result) { │
// │ case ApiSuccess(:final data): │
// │ print('Welcome, ${data.name}'); │
// │ case ApiFailure(:final code, :final message): │
// │ print('Error $code: $message'); │
// │ } │
// │ } │
// └──────────────────────────────────────────────────────────────────────────┘
//
// ┌── Cancel all in-flight requests (e.g. from a BLoC's close()) ────────────┐
// │ │
// │ @override │
// │ Future<void> close() { │
// │ ApiClient.cancel(); // safe — client stays usable for new calls │
// │ return super.close(); │
// │ } │
// └──────────────────────────────────────────────────────────────────────────┘