Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions lib/src/core/network/api_client_factory.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import 'package:http/http.dart' as http;
import 'package:version/version.dart';

import 'package:thunder/src/core/cache/platform_version_cache.dart';
import 'package:thunder/src/core/enums/threadiverse_platform.dart';
import 'package:thunder/src/core/network/lemmy/lemmy_v3_api_client.dart';
import 'package:thunder/src/core/network/lemmy/lemmy_v4_api_client.dart';
import 'package:thunder/src/core/network/piefed/piefed_api_client.dart';
import 'package:thunder/src/core/network/thunder_api_client.dart';
import 'package:thunder/src/features/account/account.dart';

/// Factory for creating the appropriate API client based on platform and version.
///
/// Example usage:
/// ```dart
/// final api = ApiClientFactory.create(account);
/// final posts = await api.getPosts();
/// ```
class ApiClientFactory {
/// Create a new API client for the given account.
///
/// The factory will automatically select the appropriate client based on:
/// - The account's platform (Lemmy, PieFed, etc.)
/// - The instance's API version (from PlatformVersionCache)
static ThunderApiClient create(
Account account, {
bool debug = false,
http.Client? httpClient,
}) {
return switch (account.platform) {
ThreadiversePlatform.lemmy => _createLemmyClient(account, debug, httpClient),
ThreadiversePlatform.piefed => _createPiefedClient(account, debug, httpClient),
_ => throw UnsupportedError('Unsupported platform: ${account.platform}'),
};
}

/// Create the appropriate Lemmy client based on version.
static ThunderApiClient _createLemmyClient(
Account account,
bool debug,
http.Client? httpClient,
) {
final version = PlatformVersionCache().get(account.instance);

// Check if the instance requires v4 API (Lemmy 1.0.0+)
if (version != null && _isLemmyApiV4(version)) {
return LemmyV4ApiClient(
account: account,
debug: debug,
version: version,
httpClient: httpClient,
);
}

// Default to v3 API for older instances or when version is unknown
return LemmyV3ApiClient(
account: account,
debug: debug,
version: version,
httpClient: httpClient,
);
}

/// Create a PieFed client.
static ThunderApiClient _createPiefedClient(
Account account,
bool debug,
http.Client? httpClient,
) {
final version = PlatformVersionCache().get(account.instance);

return PiefedApiClient(
account: account,
debug: debug,
version: version,
httpClient: httpClient,
);
}

/// Check if the Lemmy version requires the v4 API.
///
/// Lemmy 1.0.0+ uses the v4 API. Pre-releases (alpha, beta, rc) of 1.0.0 should also use v4.
static bool _isLemmyApiV4(Version version) {
return version.major >= 1;
}

/// Create a mock client for testing.
static ThunderApiClient createForTesting({
required Account account,
required http.Client mockHttpClient,
bool debug = false,
}) {
return create(account, debug: debug, httpClient: mockHttpClient);
}
}
111 changes: 111 additions & 0 deletions lib/src/core/network/api_exception.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/// Handles all API errors and exceptions.
library;

/// Base exception for all API errors.
sealed class ApiException implements Exception {
/// The error message.
String get message;

/// The error code returned by the API.
String? get errorCode;

/// The HTTP status code of the response.
int? get statusCode;

/// The platform name (e.g., 'Lemmy', 'PieFed') for error context.
String? get platformName;
}

/// Network/HTTP-level exception for connection failures, timeouts, etc.
class NetworkException implements ApiException {
@override
final String message;

@override
final int? statusCode;

@override
final String? platformName;

NetworkException(this.message, {this.statusCode, this.platformName});

@override
String? get errorCode => statusCode?.toString();

@override
String toString() => '${platformName ?? 'Network'} Error: $message';
}

/// API returned an error response (e.g., 4xx, 5xx status codes).
class ApiErrorException implements ApiException {
@override
final String message;

@override
final String? errorCode;

@override
final int? statusCode;

@override
final String? platformName;

ApiErrorException(
this.message, {
this.errorCode,
this.statusCode,
this.platformName,
});

@override
String toString() => '${platformName ?? 'API'} Error [$statusCode]: $message';
}

/// Feature not supported on the current platform.
class UnsupportedFeatureException implements ApiException {
/// The feature that is not supported.
final String feature;

@override
final String? platformName;

UnsupportedFeatureException(this.feature, {this.platformName});

@override
String get message => '$feature is not supported${platformName != null ? ' on $platformName' : ''}';

@override
String? get errorCode => 'unsupported_feature';

@override
int? get statusCode => null;

@override
String toString() => message;
}

/// Rate limit exceeded.
class RateLimitException implements ApiException {
@override
final String message;

/// Duration to wait before retrying, if provided by the server.
final Duration? retryAfter;

@override
final String? platformName;

RateLimitException(this.message, {this.retryAfter, this.platformName});

@override
String? get errorCode => 'rate_limit_error';

@override
int? get statusCode => 429;

@override
String toString() {
final waitMessage = retryAfter != null ? ' Retry after ${retryAfter!.inSeconds}s.' : '';
return '${platformName ?? 'API'} Rate Limit: $message$waitMessage';
}
}
162 changes: 162 additions & 0 deletions lib/src/core/network/base_api_client.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import 'dart:convert';

import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:version/version.dart';

import 'package:thunder/src/core/network/api_exception.dart';
import 'package:thunder/src/core/update/check_github_update.dart';
import 'package:thunder/src/features/account/account.dart';

/// HTTP methods supported by the API.
enum HttpMethod { get, post, put, delete }

/// Base class containing shared HTTP infrastructure for all API clients.
/// Handles requests, responses, and errors for all API clients.
///
/// Subclasses must implement [basePath], [platformName], and [uploadImage].
abstract class BaseApiClient {
/// The account to use for API calls (contains instance URL and JWT).
final Account account;

/// Whether to show debug information.
final bool debug;

/// The version of the platform (used for version-specific behavior).
final Version? version;

/// HTTP client for making requests. Can be injected for testing.
final http.Client httpClient;

/// Creates a new API client.
///
/// An optional [httpClient] can be provided for testing purposes.
BaseApiClient({
required this.account,
this.debug = false,
required this.version,
http.Client? httpClient,
}) : httpClient = httpClient ?? http.Client();

/// The base path for API endpoints (e.g., '/api/v3', '/api/alpha').
String get basePath;

/// Platform identifier for logging and error messages.
String get platformName;

/// Build headers with optional JWT authorization.
@protected
Map<String, String> buildHeaders() {
final appVersion = getCurrentVersion(removeInternalBuildNumber: true, trimV: true);

return {
'User-Agent': 'Thunder/$appVersion',
'Content-Type': 'application/json',
'Accept': 'application/json',
if (account.jwt != null) 'Authorization': 'Bearer ${account.jwt}',
};
}

/// Handle response from the request.
///
/// Throws appropriate exceptions for error responses:
/// - [RateLimitException] for 429 status
/// - [ApiErrorException] for other non-200 responses
@protected
Future<dynamic> handleResponse(Uri uri, http.Response response) async {
// Check for rate limiting
if (response.statusCode == 429) {
throw RateLimitException(
'Rate limit exceeded',
platformName: platformName,
retryAfter: _parseRetryAfter(response.headers['retry-after']),
);
}

// Check for error responses
if (response.statusCode != 200) {
if (debug) debugPrint('$platformName API: Failed request to $uri: ${response.statusCode}');

// Try to parse error code from response body
String? errorCode;

try {
final json = jsonDecode(response.body);
errorCode = json['error'] as String? ?? json['error_code'] as String?;
} catch (_) {
// Body is not JSON or doesn't contain error field
}

throw ApiErrorException(
response.body,
statusCode: response.statusCode,
errorCode: errorCode ?? response.statusCode.toString(),
platformName: platformName,
);
}

return compute(jsonDecode, response.body);
}

/// Parse the Retry-After header value to a Duration.
Duration? _parseRetryAfter(String? value) {
if (value == null) return null;
final seconds = int.tryParse(value);
return seconds != null ? Duration(seconds: seconds) : null;
}

/// Makes an HTTP request with the specified method.
///
/// [method] - The HTTP method to use.
/// [endpoint] - The API endpoint (should include [basePath]).
/// [data] - Request parameters/body data.
///
/// Returns the parsed JSON response body. Throws [ApiException] subclasses on errors.
Future<Map<String, dynamic>> request(
HttpMethod method,
String endpoint,
Map<String, dynamic> data,
) async {
try {
final headers = buildHeaders();
data.removeWhere((key, value) => value == null);

Uri uri;
http.Response response;

switch (method) {
case HttpMethod.get:
// Convert all values to strings for query parameters
final queryParams = data.map((k, v) => MapEntry(k, v.toString()));
uri = Uri.https(account.instance, endpoint, queryParams.isEmpty ? null : queryParams);
if (debug) debugPrint('$platformName API: GET $uri');
response = await httpClient.get(uri, headers: headers);

case HttpMethod.post:
uri = Uri.https(account.instance, endpoint);
if (debug) debugPrint('$platformName API: POST $uri');
response = await httpClient.post(uri, body: jsonEncode(data), headers: headers);

case HttpMethod.put:
uri = Uri.https(account.instance, endpoint);
if (debug) debugPrint('$platformName API: PUT $uri');
response = await httpClient.put(uri, body: jsonEncode(data), headers: headers);

case HttpMethod.delete:
uri = Uri.https(account.instance, endpoint);
if (debug) debugPrint('$platformName API: DELETE $uri');
response = await httpClient.delete(uri, headers: headers);
}

return await handleResponse(uri, response) as Map<String, dynamic>;
} catch (e) {
if (debug) debugPrint('$platformName API: Error: $e');
rethrow;
}
}

/// Clean up resources. Should be called when the client is no longer needed.
void dispose() {
httpClient.close();
}
}
Loading