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

2 weeks ago
// 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(); │
// │ } │
// └──────────────────────────────────────────────────────────────────────────┘