diff --git a/lib/services/api_exceptions.dart b/lib/services/api_exceptions.dart index 416a8a4..d61f72b 100644 --- a/lib/services/api_exceptions.dart +++ b/lib/services/api_exceptions.dart @@ -2,60 +2,111 @@ /// /// Custom exception classes for different types of API failures. /// This allows better error handling and user-friendly error messages. +/// +/// [ApiException.fromDioError] is the single canonical mapping from +/// [DioException] to these types — services must not hand-roll their own +/// status-code switches. library; import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; + +import 'log_redaction.dart'; /// Base class for all API exceptions class ApiException implements Exception { ApiException(this.message, {this.statusCode, this.originalError}); - /// Create ApiException from DioException + /// Canonical mapping from [DioException] to a typed [ApiException]. + /// + /// When the server responded, the message is taken from the XRPC error + /// body (human-readable `message` preferred over the machine `error` + /// code, plain-text bodies used as-is) and the status code picks the + /// type: 401 → [AuthenticationException], 404 → [NotFoundException], + /// 5xx → [ServerException], anything else → [ApiException]. + /// + /// Without a response, the Dio error type picks the type: timeouts and + /// connection failures → [NetworkException], DNS resolution failures → + /// [FederationException] (the PDS may be unreachable). factory ApiException.fromDioError(DioException error) { + final response = error.response; + final statusCode = response?.statusCode; + + if (response != null && statusCode != null) { + // Handle both JSON error responses and plain text responses + String? message; + final data = response.data; + if (data is Map) { + message = data['message'] as String? ?? data['error'] as String?; + } else if (data is String && data.isNotEmpty) { + message = data; + } + + if (statusCode == 401) { + return AuthenticationException( + message ?? 'Authentication failed. Token expired or invalid', + originalError: error, + ); + } + if (statusCode == 404) { + return NotFoundException( + message ?? 'Resource not found. PDS or content may not exist', + originalError: error, + ); + } + if (statusCode >= 500) { + return ServerException( + message ?? 'Server error. Please try again later', + statusCode: statusCode, + originalError: error, + ); + } + return ApiException( + message ?? 'Request failed with status $statusCode', + statusCode: statusCode, + originalError: error, + ); + } + + // Network-level errors (no response from server) switch (error.type) { case DioExceptionType.connectionTimeout: case DioExceptionType.sendTimeout: case DioExceptionType.receiveTimeout: return NetworkException( - 'Request timeout. Please check your connection.', + 'Connection timeout. Please check your internet connection', originalError: error, ); - case DioExceptionType.badResponse: - final statusCode = error.response?.statusCode; - final message = - error.response?.data?['message'] as String? ?? - error.response?.data?['error'] as String? ?? - 'Server error'; - - if (statusCode == 401) { - return AuthenticationException(message, originalError: error); - } else if (statusCode == 404) { - return NotFoundException(message, originalError: error); - } else if (statusCode != null && statusCode >= 500) { - return ServerException( - message, - statusCode: statusCode, + case DioExceptionType.connectionError: + // Could be federation issue if it's a PDS connection failure + if (error.message?.contains('Failed host lookup') ?? false) { + return FederationException( + 'Failed to connect to PDS. Server may be unreachable', originalError: error, ); } - return ApiException( - message, - statusCode: statusCode, - originalError: error, - ); - case DioExceptionType.cancel: - return ApiException('Request was cancelled', originalError: error); - case DioExceptionType.connectionError: return NetworkException( - 'Connection failed. Please check your internet.', + 'Network error. Please check your internet connection', originalError: error, ); case DioExceptionType.badCertificate: return NetworkException('SSL certificate error', originalError: error); + case DioExceptionType.cancel: + return ApiException('Request cancelled', originalError: error); + case DioExceptionType.badResponse: + // Response missing or without a status code + return ApiException( + 'Bad response from server: ${error.message}', + originalError: error, + ); case DioExceptionType.unknown: - return NetworkException('Network error occurred', originalError: error); + return NetworkException( + 'Network error: ${error.message ?? 'unknown'}', + originalError: error, + ); } } + final String message; final int? statusCode; final dynamic originalError; @@ -64,6 +115,21 @@ class ApiException implements Exception { String toString() => message; } +/// Maps [error] to a typed [ApiException] after logging it (with bearer +/// tokens redacted) in debug builds. [operation] labels the log line, +/// e.g. 'fetch timeline'. +ApiException mapDioException(DioException error, {required String operation}) { + if (kDebugMode) { + debugPrint('❌ Failed to $operation: ${error.message}'); + if (error.response != null) { + debugPrint(' Status: ${error.response?.statusCode}'); + // Response data can echo credentials — redact before printing + debugPrint(redactBearerTokens(' Data: ${error.response?.data}')); + } + } + return ApiException.fromDioError(error); +} + /// Authentication failure (401) /// Token expired, invalid, or missing class AuthenticationException extends ApiException { diff --git a/lib/services/auth_interceptor.dart b/lib/services/auth_interceptor.dart index b97030a..e04512f 100644 --- a/lib/services/auth_interceptor.dart +++ b/lib/services/auth_interceptor.dart @@ -1,14 +1,16 @@ import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; -import '../models/coves_session.dart'; +import 'log_redaction.dart'; /// Creates a Dio interceptor that handles authentication and automatic /// token refresh on 401 errors. /// -/// This shared utility eliminates duplication between VoteService and -/// CommentService by providing a single implementation of: +/// This shared utility is the single implementation used by +/// CovesApiService, VoteService, and CommentService of: /// - Adding Authorization headers with fresh tokens on each request +/// (fetched per-request because atProto OAuth rotates tokens ~hourly; +/// caching a token would cause 401s after the first expiry) /// - Automatic retry with token refresh on 401 responses /// - Sign-out when a 401 persists after a successful refresh /// @@ -21,15 +23,16 @@ import '../models/coves_session.dart'; /// ```dart /// _dio.interceptors.add( /// createAuthInterceptor( -/// sessionGetter: () async => authProvider.session, +/// tokenGetter: () async => authProvider.session?.token, /// tokenRefresher: authProvider.refreshToken, /// signOutHandler: authProvider.signOut, /// serviceName: 'MyService', +/// dio: _dio, /// ), /// ); /// ``` InterceptorsWrapper createAuthInterceptor({ - required Future Function()? sessionGetter, + required Future Function()? tokenGetter, required Future Function()? tokenRefresher, required Future Function()? signOutHandler, required String serviceName, @@ -38,16 +41,16 @@ InterceptorsWrapper createAuthInterceptor({ return InterceptorsWrapper( onRequest: (options, handler) async { // Fetch fresh token before each request - final session = await sessionGetter?.call(); - if (session != null) { - options.headers['Authorization'] = 'Bearer ${session.token}'; + final token = await tokenGetter?.call(); + if (token != null) { + options.headers['Authorization'] = 'Bearer $token'; if (kDebugMode) { debugPrint('🔐 $serviceName: Adding fresh Authorization header'); } } else { if (kDebugMode) { debugPrint( - '⚠️ $serviceName: Session getter returned null - ' + '⚠️ $serviceName: No token available - ' 'making unauthenticated request', ); } @@ -63,6 +66,24 @@ InterceptorsWrapper createAuthInterceptor({ ); } + // Don't retry the refresh endpoint itself (avoid infinite loop) + final isRefreshEndpoint = error.requestOptions.path.contains( + '/oauth/refresh', + ); + if (isRefreshEndpoint) { + if (kDebugMode) { + debugPrint( + '⚠️ $serviceName: Refresh endpoint returned 401, ' + 'signing out user', + ); + } + // Refresh endpoint failed, sign out the user + if (signOutHandler != null) { + await signOutHandler(); + } + return handler.next(error); + } + // Check if we already retried this request (prevent infinite loop) if (error.requestOptions.extra['retried'] == true) { if (kDebugMode) { @@ -89,16 +110,16 @@ InterceptorsWrapper createAuthInterceptor({ ); } - // Get the new session - final newSession = await sessionGetter?.call(); + // Get the new token + final newToken = await tokenGetter?.call(); - if (newSession != null) { + if (newToken != null) { // Mark this request as retried to prevent infinite loops error.requestOptions.extra['retried'] = true; // Update the Authorization header with the new token error.requestOptions.headers['Authorization'] = - 'Bearer ${newSession.token}'; + 'Bearer $newToken'; // Retry the original request with the new token try { @@ -142,7 +163,8 @@ InterceptorsWrapper createAuthInterceptor({ debugPrint('❌ $serviceName API Error: ${error.message}'); if (error.response != null) { debugPrint(' Status: ${error.response?.statusCode}'); - debugPrint(' Data: ${error.response?.data}'); + // Response data can echo credentials — redact before printing + debugPrint(redactBearerTokens(' Data: ${error.response?.data}')); } } return handler.next(error); diff --git a/lib/services/comment_service.dart b/lib/services/comment_service.dart index e3bcaa0..803b975 100644 --- a/lib/services/comment_service.dart +++ b/lib/services/comment_service.dart @@ -57,7 +57,7 @@ class CommentService { // Add shared 401 retry interceptor _dio.interceptors.add( createAuthInterceptor( - sessionGetter: sessionGetter, + tokenGetter: () async => (await sessionGetter?.call())?.token, tokenRefresher: tokenRefresher, signOutHandler: signOutHandler, serviceName: 'CommentService', @@ -148,24 +148,7 @@ class CommentService { return CreateCommentResponse(uri: uri, cid: cid); } on DioException catch (e) { - if (kDebugMode) { - debugPrint('❌ Comment creation failed: ${e.message}'); - debugPrint(' Status: ${e.response?.statusCode}'); - debugPrint(' Data: ${e.response?.data}'); - } - - if (e.response?.statusCode == 401) { - throw AuthenticationException( - 'Authentication failed. Please sign in again.', - originalError: e, - ); - } - - throw ApiException( - 'Failed to create comment: ${e.message}', - statusCode: e.response?.statusCode, - originalError: e, - ); + throw mapDioException(e, operation: 'create comment'); } on AuthenticationException { rethrow; } on ApiException { @@ -185,7 +168,8 @@ class CommentService { /// /// Throws: /// - AuthenticationException if not authenticated - /// - ApiException with 'You can only delete your own comments' if not the comment author + /// - ApiException with 'You can only delete your own comments' if not + /// the comment author /// - ApiException for other errors Future deleteComment({required String uri}) async { try { @@ -210,17 +194,8 @@ class CommentService { debugPrint('✅ Comment deleted successfully'); } } on DioException catch (e) { - if (kDebugMode) { - debugPrint('❌ Comment deletion failed: ${e.message}'); - } - - if (e.response?.statusCode == 401) { - throw AuthenticationException( - 'Authentication failed. Please sign in again.', - originalError: e, - ); - } - + // Friendlier copy than the server's for the two expected outcomes; + // everything else goes through the canonical mapper. if (e.response?.statusCode == 403) { throw ApiException( 'You can only delete your own comments', @@ -228,35 +203,13 @@ class CommentService { originalError: e, ); } - if (e.response?.statusCode == 404) { throw NotFoundException( 'Comment not found. It may have already been deleted.', originalError: e, ); } - - // Handle network-level errors - if (e.response == null) { - switch (e.type) { - case DioExceptionType.connectionTimeout: - case DioExceptionType.sendTimeout: - case DioExceptionType.receiveTimeout: - case DioExceptionType.connectionError: - throw NetworkException( - 'Network error. Please check your connection.', - originalError: e, - ); - default: - break; - } - } - - throw ApiException( - 'Failed to delete comment: ${e.message}', - statusCode: e.response?.statusCode, - originalError: e, - ); + throw mapDioException(e, operation: 'delete comment'); } on AuthenticationException { rethrow; } on NotFoundException { diff --git a/lib/services/coves_api_service.dart b/lib/services/coves_api_service.dart index c30e471..bf00e17 100644 --- a/lib/services/coves_api_service.dart +++ b/lib/services/coves_api_service.dart @@ -10,6 +10,8 @@ import '../models/post.dart'; import '../models/post_get_result.dart'; import '../models/user_profile.dart'; import 'api_exceptions.dart'; +import 'auth_interceptor.dart'; +import 'log_redaction.dart'; import 'retry_interceptor.dart'; /// Coves API Service @@ -22,7 +24,8 @@ import 'retry_interceptor.dart'; /// rotates tokens automatically (~1 hour expiry), and caching tokens would /// cause 401 errors after the first token expires. /// -/// Features automatic token refresh on 401 responses: +/// Features automatic token refresh on 401 responses (see +/// [createAuthInterceptor]): /// - When a 401 is received, attempts to refresh the token /// - Retries the original request with the new token /// - If refresh fails, propagates the error - sign-out is owned by the @@ -34,9 +37,7 @@ class CovesApiService { Future Function()? tokenRefresher, Future Function()? signOutHandler, Dio? dio, - }) : _tokenGetter = tokenGetter, - _tokenRefresher = tokenRefresher, - _signOutHandler = signOutHandler { + }) { _dio = dio ?? Dio( @@ -59,145 +60,14 @@ class CovesApiService { ), ); - // Add auth interceptor to add bearer token + // Add shared auth interceptor (bearer token + 401 refresh/retry) _dio.interceptors.add( - InterceptorsWrapper( - onRequest: (options, handler) async { - // Fetch fresh token before each request (critical for atProto OAuth) - if (_tokenGetter != null) { - final token = await _tokenGetter(); - if (token != null) { - options.headers['Authorization'] = 'Bearer $token'; - if (kDebugMode) { - debugPrint('🔐 Adding fresh Authorization header'); - } - } else { - if (kDebugMode) { - debugPrint( - '⚠️ Token getter returned null - ' - 'making unauthenticated request', - ); - } - } - } else { - if (kDebugMode) { - debugPrint( - '⚠️ No token getter provided - ' - 'making unauthenticated request', - ); - } - } - return handler.next(options); - }, - onError: (error, handler) async { - // Handle 401 errors with automatic token refresh - if (error.response?.statusCode == 401 && _tokenRefresher != null) { - if (kDebugMode) { - debugPrint('🔄 401 detected, attempting token refresh...'); - } - - // Don't retry the refresh endpoint itself (avoid infinite loop) - final isRefreshEndpoint = error.requestOptions.path.contains( - '/oauth/refresh', - ); - if (isRefreshEndpoint) { - if (kDebugMode) { - debugPrint( - '⚠️ Refresh endpoint returned 401, signing out user', - ); - } - // Refresh endpoint failed, sign out the user - if (_signOutHandler != null) { - await _signOutHandler(); - } - return handler.next(error); - } - - // Check if we already retried this request (prevent infinite loop) - if (error.requestOptions.extra['retried'] == true) { - if (kDebugMode) { - debugPrint( - '⚠️ Request already retried after token refresh, ' - 'signing out user', - ); - } - // Already retried once, don't retry again - if (_signOutHandler != null) { - await _signOutHandler(); - } - return handler.next(error); - } - - try { - // Attempt to refresh the token - final refreshSucceeded = await _tokenRefresher(); - - if (refreshSucceeded) { - if (kDebugMode) { - debugPrint('✅ Token refresh successful, retrying request'); - } - - // Get the new token - final newToken = - _tokenGetter != null ? await _tokenGetter() : null; - - if (newToken != null) { - // Mark this request as retried to prevent infinite loops - error.requestOptions.extra['retried'] = true; - - // Update the Authorization header with the new token - error.requestOptions.headers['Authorization'] = - 'Bearer $newToken'; - - // Retry the original request with the new token - try { - final response = await _dio.fetch(error.requestOptions); - return handler.resolve(response); - } on DioException catch (retryError) { - // If retry failed with 401 and already retried, we already - // signed out in the retry limit check above, so just pass - // the error through without signing out again - if (retryError.response?.statusCode == 401 && - retryError.requestOptions.extra['retried'] == true) { - return handler.next(retryError); - } - // For other errors during retry, rethrow to outer catch - rethrow; - } - } - } - - // Refresh failed. Do NOT sign out here: the refresher owns - // that decision and already signed out if the session was - // definitively rejected. A false return may just mean a - // transient network failure, and signing out would destroy - // a valid session. - if (kDebugMode) { - debugPrint('❌ Token refresh failed, propagating error'); - } - } catch (e) { - // Same rule as above: an exception here (from the refresher - // or from retrying the original request) is not evidence the - // session is dead, so never sign out - just propagate. - if (kDebugMode) { - debugPrint('❌ Error during token refresh: $e'); - } - } - } - - // Log the error for debugging - if (kDebugMode) { - debugPrint('❌ API Error: ${error.message}'); - if (error.response != null) { - debugPrint(' Status: ${error.response?.statusCode}'); - // Response data can echo credentials — redact before printing - debugPrint( - redactBearerTokens(' Data: ${error.response?.data}'), - ); - } - } - return handler.next(error); - }, + createAuthInterceptor( + tokenGetter: tokenGetter, + tokenRefresher: tokenRefresher, + signOutHandler: signOutHandler, + serviceName: 'CovesApiService', + dio: _dio, ), ); @@ -215,29 +85,89 @@ class CovesApiService { } } - /// Matches a bearer scheme (case-insensitive) followed by any run of - /// non-whitespace characters. Greedy on purpose: a charset-based match - /// would leak the tail of tokens containing characters outside the set. - static final RegExp _bearerTokenPattern = RegExp( - r'Bearer\s+\S+', - caseSensitive: false, - ); - - /// Replaces bearer token values with a placeholder so credentials never - /// appear in logs. - @visibleForTesting - static String redactBearerTokens(String line) { - return line.replaceAll(_bearerTokenPattern, 'Bearer [REDACTED]'); - } - /// Maximum number of URIs per [getPosts] call, per the /// social.coves.community.post.get lexicon (`uris` has `maxLength: 25`). static const int maxPostGetUris = 25; late final Dio _dio; - final Future Function()? _tokenGetter; - final Future Function()? _tokenRefresher; - final Future Function()? _signOutHandler; + + /// Runs one API call with the shared error taxonomy. + /// + /// Every endpoint goes through here so error handling cannot drift: + /// - [DioException] is mapped by [mapDioException] (the canonical mapper) + /// - [ApiException]s thrown by [parse] (e.g. invalid response shape) + /// propagate untouched + /// - anything else [parse] throws (FormatException, TypeError, ...) + /// becomes a generic parse [ApiException], so callers only ever see + /// the [ApiException] taxonomy + /// + /// [operation] is a human-readable label used in error messages and debug + /// logs, e.g. 'fetch timeline'. + Future _request({ + required String operation, + required Future> Function() send, + required T Function(Object? data) parse, + }) async { + try { + if (kDebugMode) { + debugPrint('📡 Starting: $operation'); + } + + final response = await send(); + final result = parse(response.data); + + if (kDebugMode) { + debugPrint('✅ Succeeded: $operation'); + } + + return result; + } on DioException catch (e) { + throw mapDioException(e, operation: operation); + } on ApiException { + rethrow; + } catch (e) { + if (kDebugMode) { + debugPrint('❌ Error parsing $operation response: $e'); + } + throw ApiException('Failed to parse server response', originalError: e); + } + } + + /// Casts a response body to a JSON object, throwing [FormatException] + /// when the server returned something else. + static Map _asJsonMap(Object? data) { + if (data is! Map) { + throw FormatException('Expected Map but got ${data.runtimeType}'); + } + return data; + } + + /// Shared implementation for the three feed endpoints, which take the + /// same parameters and return the same shape. + Future _getFeed( + String path, + String operation, { + String? community, + required String sort, + String? timeframe, + required int limit, + String? cursor, + }) { + return _request( + operation: operation, + send: () => _dio.get( + path, + queryParameters: { + if (community != null) 'community': community, + 'sort': sort, + 'limit': limit, + if (timeframe != null) 'timeframe': timeframe, + if (cursor != null) 'cursor': cursor, + }, + ), + parse: (data) => TimelineResponse.fromJson(_asJsonMap(data)), + ); + } /// Get timeline feed (authenticated, personalized) /// @@ -255,43 +185,15 @@ class CovesApiService { String? timeframe, int limit = 15, String? cursor, - }) async { - try { - if (kDebugMode) { - debugPrint('📡 Fetching timeline: sort=$sort, limit=$limit'); - } - - final queryParams = {'sort': sort, 'limit': limit}; - - if (timeframe != null) { - queryParams['timeframe'] = timeframe; - } - - if (cursor != null) { - queryParams['cursor'] = cursor; - } - - final response = await _dio.get( - '/xrpc/social.coves.feed.getTimeline', - queryParameters: queryParams, - ); - - if (kDebugMode) { - debugPrint( - '✅ Timeline fetched: ' - '${response.data['feed']?.length ?? 0} posts', - ); - } - - return TimelineResponse.fromJson(response.data as Map); - } on DioException catch (e) { - _handleDioException(e, 'timeline'); - } catch (e) { - if (kDebugMode) { - debugPrint('❌ Error parsing timeline response: $e'); - } - throw ApiException('Failed to parse server response', originalError: e); - } + }) { + return _getFeed( + '/xrpc/social.coves.feed.getTimeline', + 'fetch timeline', + sort: sort, + timeframe: timeframe, + limit: limit, + cursor: cursor, + ); } /// Get discover feed (public, no auth required) @@ -303,43 +205,15 @@ class CovesApiService { String? timeframe, int limit = 15, String? cursor, - }) async { - try { - if (kDebugMode) { - debugPrint('📡 Fetching discover feed: sort=$sort, limit=$limit'); - } - - final queryParams = {'sort': sort, 'limit': limit}; - - if (timeframe != null) { - queryParams['timeframe'] = timeframe; - } - - if (cursor != null) { - queryParams['cursor'] = cursor; - } - - final response = await _dio.get( - '/xrpc/social.coves.feed.getDiscover', - queryParameters: queryParams, - ); - - if (kDebugMode) { - debugPrint( - '✅ Discover feed fetched: ' - '${response.data['feed']?.length ?? 0} posts', - ); - } - - return TimelineResponse.fromJson(response.data as Map); - } on DioException catch (e) { - _handleDioException(e, 'discover feed'); - } catch (e) { - if (kDebugMode) { - debugPrint('❌ Error parsing discover feed response: $e'); - } - throw ApiException('Failed to parse server response', originalError: e); - } + }) { + return _getFeed( + '/xrpc/social.coves.feed.getDiscover', + 'fetch discover feed', + sort: sort, + timeframe: timeframe, + limit: limit, + cursor: cursor, + ); } /// Get community feed (public, no auth required) @@ -361,50 +235,16 @@ class CovesApiService { String? timeframe, int limit = 15, String? cursor, - }) async { - try { - if (kDebugMode) { - debugPrint( - '📡 Fetching community feed: community=$community, ' - 'sort=$sort, limit=$limit', - ); - } - - final queryParams = { - 'community': community, - 'sort': sort, - 'limit': limit, - }; - - if (timeframe != null) { - queryParams['timeframe'] = timeframe; - } - - if (cursor != null) { - queryParams['cursor'] = cursor; - } - - final response = await _dio.get( - '/xrpc/social.coves.communityFeed.getCommunity', - queryParameters: queryParams, - ); - - if (kDebugMode) { - debugPrint( - '✅ Community feed fetched: ' - '${response.data['feed']?.length ?? 0} posts', - ); - } - - return TimelineResponse.fromJson(response.data as Map); - } on DioException catch (e) { - _handleDioException(e, 'community feed'); - } catch (e) { - if (kDebugMode) { - debugPrint('❌ Error parsing community feed response: $e'); - } - throw ApiException('Failed to parse server response', originalError: e); - } + }) { + return _getFeed( + '/xrpc/social.coves.communityFeed.getCommunity', + 'fetch community feed', + community: community, + sort: sort, + timeframe: timeframe, + limit: limit, + cursor: cursor, + ); } /// Get comments for a post (authenticated) @@ -431,52 +271,24 @@ class CovesApiService { int limit = 50, String? cursor, String? parentRkey, - }) async { - try { - if (kDebugMode) { - debugPrint('📡 Fetching comments: postUri=$postUri, sort=$sort'); - } - - final queryParams = { - 'post': postUri, - 'sort': sort, - 'depth': depth, - 'limit': limit, - }; - - if (parentRkey != null && parentRkey.isNotEmpty) { - queryParams['parentRkey'] = parentRkey; - } - - if (timeframe != null) { - queryParams['timeframe'] = timeframe; - } - - if (cursor != null) { - queryParams['cursor'] = cursor; - } - - final response = await _dio.get( + }) { + return _request( + operation: 'fetch comments', + send: () => _dio.get( '/xrpc/social.coves.community.comment.getComments', - queryParameters: queryParams, - ); - - if (kDebugMode) { - debugPrint( - '✅ Comments fetched: ' - '${response.data['comments']?.length ?? 0} comments', - ); - } - - return CommentsResponse.fromJson(response.data as Map); - } on DioException catch (e) { - _handleDioException(e, 'comments'); - } catch (e) { - if (kDebugMode) { - debugPrint('❌ Error parsing comments response: $e'); - } - throw ApiException('Failed to parse server response', originalError: e); - } + queryParameters: { + 'post': postUri, + 'sort': sort, + 'depth': depth, + 'limit': limit, + if (parentRkey != null && parentRkey.isNotEmpty) + 'parentRkey': parentRkey, + if (timeframe != null) 'timeframe': timeframe, + if (cursor != null) 'cursor': cursor, + }, + ), + parse: (data) => CommentsResponse.fromJson(_asJsonMap(data)), + ); } /// Get posts by AT-URI (public, optional auth) @@ -493,7 +305,7 @@ class CovesApiService { /// Parameters: /// - [uris]: 1 to [maxPostGetUris] post AT-URIs (throws [ArgumentError] /// otherwise) - Future> getPosts({required List uris}) async { + Future> getPosts({required List uris}) { if (uris.isEmpty) { throw ArgumentError.value(uris, 'uris', 'must not be empty'); } @@ -505,55 +317,42 @@ class CovesApiService { ); } - try { - if (kDebugMode) { - debugPrint('📡 Fetching posts: ${uris.length} URIs'); - } - + return _request( + operation: 'fetch posts', // atproto expects repeated `uris=a&uris=b` params; pin ListFormat.multi // explicitly so the required encoding can't change with Dio defaults. - final response = await _dio.get( + send: () => _dio.get( '/xrpc/social.coves.community.post.get', queryParameters: {'uris': uris}, options: Options(listFormat: ListFormat.multi), - ); - - final data = response.data as Map; - final posts = data['posts'] as List? ?? []; - - final results = []; - for (var i = 0; i < posts.length; i++) { - final item = posts[i]; - try { - results.add(PostGetResult.fromJson(item as Map)); - } on Object catch (e) { - // Degrade a single malformed entry to notFound instead of failing - // the whole batch. Read the uri defensively; fall back to the - // corresponding input URI (server guarantees order). - final fallbackUri = - (item is Map && item['uri'] is String) - ? item['uri'] as String - : (i < uris.length ? uris[i] : ''); - if (kDebugMode) { - debugPrint('⚠️ Failed to parse post entry $i ($fallbackUri): $e'); + ), + parse: (data) { + final posts = _asJsonMap(data)['posts'] as List? ?? []; + + final results = []; + for (var i = 0; i < posts.length; i++) { + final item = posts[i]; + try { + results.add(PostGetResult.fromJson(item as Map)); + } on Object catch (e) { + // Degrade a single malformed entry to notFound instead of failing + // the whole batch. Read the uri defensively; fall back to the + // corresponding input URI (server guarantees order). + final fallbackUri = + (item is Map && item['uri'] is String) + ? item['uri'] as String + : (i < uris.length ? uris[i] : ''); + if (kDebugMode) { + debugPrint( + '⚠️ Failed to parse post entry $i ($fallbackUri): $e', + ); + } + results.add(PostGetNotFound(fallbackUri)); } - results.add(PostGetNotFound(fallbackUri)); } - } - - if (kDebugMode) { - debugPrint('✅ Posts fetched: ${results.length} results'); - } - - return results; - } on DioException catch (e) { - _handleDioException(e, 'posts'); - } catch (e) { - if (kDebugMode) { - debugPrint('❌ Error parsing posts response: $e'); - } - throw ApiException('Failed to parse server response', originalError: e); - } + return results; + }, + ); } /// Get a single post by AT-URI (public, optional auth) @@ -579,55 +378,29 @@ class CovesApiService { /// Parameters: /// - [limit]: Number of communities per page (default: 50, max: 100) /// - [cursor]: Pagination cursor from previous response - /// - [sort]: Sort order - 'popular', 'new', or 'alphabetical' (default: 'popular') - /// - [subscribed]: If true, only return communities the user is subscribed to + /// - [sort]: Sort order - 'popular', 'new', or 'alphabetical' + /// (default: 'popular') + /// - [subscribed]: If true, only return communities the user is + /// subscribed to Future listCommunities({ int limit = 50, String? cursor, String sort = 'popular', bool? subscribed, - }) async { - try { - if (kDebugMode) { - debugPrint( - '📡 Fetching communities: sort=$sort, limit=$limit, ' - 'subscribed=$subscribed', - ); - } - - final queryParams = {'limit': limit, 'sort': sort}; - - if (cursor != null) { - queryParams['cursor'] = cursor; - } - - if (subscribed == true) { - queryParams['subscribed'] = 'true'; - } - - final response = await _dio.get( + }) { + return _request( + operation: 'fetch communities', + send: () => _dio.get( '/xrpc/social.coves.community.list', - queryParameters: queryParams, - ); - - if (kDebugMode) { - debugPrint( - '✅ Communities fetched: ' - '${response.data['communities']?.length ?? 0} communities', - ); - } - - return CommunitiesResponse.fromJson( - response.data as Map, - ); - } on DioException catch (e) { - _handleDioException(e, 'communities'); - } catch (e) { - if (kDebugMode) { - debugPrint('❌ Error parsing communities response: $e'); - } - throw ApiException('Failed to parse server response', originalError: e); - } + queryParameters: { + 'limit': limit, + 'sort': sort, + if (cursor != null) 'cursor': cursor, + if (subscribed ?? false) 'subscribed': 'true', + }, + ), + parse: (data) => CommunitiesResponse.fromJson(_asJsonMap(data)), + ); } /// Get a single community by identifier @@ -637,30 +410,15 @@ class CovesApiService { /// /// Parameters: /// - [community]: Community DID or handle (required) - Future getCommunity({required String community}) async { - try { - if (kDebugMode) { - debugPrint('📡 Fetching community: $community'); - } - - final response = await _dio.get( + Future getCommunity({required String community}) { + return _request( + operation: 'fetch community', + send: () => _dio.get( '/xrpc/social.coves.community.get', queryParameters: {'community': community}, - ); - - if (kDebugMode) { - debugPrint('✅ Community fetched: ${response.data['name']}'); - } - - return CommunityView.fromJson(response.data as Map); - } on DioException catch (e) { - _handleDioException(e, 'community'); - } catch (e) { - if (kDebugMode) { - debugPrint('❌ Error parsing community response: $e'); - } - throw ApiException('Failed to parse server response', originalError: e); - } + ), + parse: (data) => CommunityView.fromJson(_asJsonMap(data)), + ); } /// Create a new post in a community @@ -683,57 +441,24 @@ class CovesApiService { ExternalEmbedInput? embed, List? langs, SelfLabels? labels, - }) async { - try { - if (kDebugMode) { - debugPrint('📡 Creating post in community: $community'); - } - - // Build request body with only non-null fields - final requestBody = {'community': community}; - - if (title != null) { - requestBody['title'] = title; - } - - if (content != null) { - requestBody['content'] = content; - } - - if (facets != null && facets.isNotEmpty) { - requestBody['facets'] = facets.map((f) => f.toJson()).toList(); - } - - if (embed != null) { - requestBody['embed'] = embed.toJson(); - } - - if (langs != null && langs.isNotEmpty) { - requestBody['langs'] = langs; - } - - if (labels != null) { - requestBody['labels'] = labels.toJson(); - } - - final response = await _dio.post( + }) { + return _request( + operation: 'create post', + send: () => _dio.post( '/xrpc/social.coves.community.post.create', - data: requestBody, - ); - - if (kDebugMode) { - debugPrint('✅ Post created successfully'); - } - - return CreatePostResponse.fromJson(response.data as Map); - } on DioException catch (e) { - _handleDioException(e, 'create post'); - } catch (e) { - if (kDebugMode) { - debugPrint('❌ Error creating post: $e'); - } - throw ApiException('Failed to create post', originalError: e); - } + data: { + 'community': community, + if (title != null) 'title': title, + if (content != null) 'content': content, + if (facets != null && facets.isNotEmpty) + 'facets': facets.map((f) => f.toJson()).toList(), + if (embed != null) 'embed': embed.toJson(), + if (langs != null && langs.isNotEmpty) 'langs': langs, + if (labels != null) 'labels': labels.toJson(), + }, + ), + parse: (data) => CreatePostResponse.fromJson(_asJsonMap(data)), + ); } /// Delete a post @@ -749,34 +474,22 @@ class CovesApiService { /// - [ApiException] with statusCode 403 if not the post author /// - [NotFoundException] if post doesn't exist (404) /// - [NetworkException] for connection issues - Future deletePost({required String uri}) async { - try { - if (kDebugMode) { - debugPrint('🗑️ Deleting post: $uri'); - } - - await _dio.post( + Future deletePost({required String uri}) { + return _request( + operation: 'delete post', + send: () => _dio.post( '/xrpc/social.coves.community.post.delete', data: {'uri': uri}, - ); - - if (kDebugMode) { - debugPrint('✅ Post deleted successfully'); - } - } on DioException catch (e) { - _handleDioException(e, 'delete post'); - } on Exception catch (e) { - if (kDebugMode) { - debugPrint('❌ Delete post failed with unexpected error: $e'); - } - throw ApiException('Failed to delete post: $e', originalError: e); - } + ), + parse: (_) {}, + ); } /// Create a new community /// - /// Creates a new community with the given name, display name, and description. - /// Requires authentication and admin privileges (backend enforces). + /// Creates a new community with the given name, display name, and + /// description. Requires authentication and admin privileges + /// (backend enforces). /// /// Parameters: /// - [name]: DNS-valid unique identifier (e.g., "worldnews") @@ -789,39 +502,20 @@ class CovesApiService { required String displayName, required String description, String visibility = 'public', - }) async { - try { - if (kDebugMode) { - debugPrint('📡 Creating community: $name ($displayName)'); - } - - final requestBody = { - 'name': name, - 'displayName': displayName, - 'description': description, - 'visibility': visibility, - }; - - final response = await _dio.post( + }) { + return _request( + operation: 'create community', + send: () => _dio.post( '/xrpc/social.coves.community.create', - data: requestBody, - ); - - if (kDebugMode) { - debugPrint('✅ Community created successfully'); - } - - return CreateCommunityResponse.fromJson( - response.data as Map, - ); - } on DioException catch (e) { - _handleDioException(e, 'create community'); - } catch (e) { - if (kDebugMode) { - debugPrint('❌ Error creating community: $e'); - } - throw ApiException('Failed to create community', originalError: e); - } + data: { + 'name': name, + 'displayName': displayName, + 'description': description, + 'visibility': visibility, + }, + ), + parse: (data) => CreateCommunityResponse.fromJson(_asJsonMap(data)), + ); } /// Get user profile by DID or handle @@ -834,38 +528,17 @@ class CovesApiService { /// /// Throws: /// - `NotFoundException` if the user does not exist - /// - `UnauthorizedException` if authentication is required/expired + /// - `AuthenticationException` if authentication is required/expired /// - `ApiException` for other API errors - Future getProfile({required String actor}) async { - try { - if (kDebugMode) { - debugPrint('📡 Fetching profile for: $actor'); - } - - final response = await _dio.get( + Future getProfile({required String actor}) { + return _request( + operation: 'fetch profile', + send: () => _dio.get( '/xrpc/social.coves.actor.getProfile', queryParameters: {'actor': actor}, - ); - - if (kDebugMode) { - debugPrint('✅ Profile fetched for: $actor'); - } - - final data = response.data; - if (data is! Map) { - throw FormatException('Expected Map but got ${data.runtimeType}'); - } - return UserProfile.fromJson(data); - } on DioException catch (e) { - _handleDioException(e, 'profile'); // Never returns - always throws - } on FormatException { - rethrow; - } on Exception catch (e) { - if (kDebugMode) { - debugPrint('❌ Error parsing profile response: $e'); - } - throw ApiException('Failed to parse server response', originalError: e); - } + ), + parse: (data) => UserProfile.fromJson(_asJsonMap(data)), + ); } /// Get posts by a specific actor @@ -885,7 +558,7 @@ class CovesApiService { /// /// Throws: /// - `NotFoundException` if the actor does not exist - /// - `UnauthorizedException` if authentication is required/expired + /// - `AuthenticationException` if authentication is required/expired /// - `ApiException` for other API errors Future getAuthorPosts({ required String actor, @@ -893,57 +566,21 @@ class CovesApiService { String? community, int limit = 15, String? cursor, - }) async { - try { - if (kDebugMode) { - debugPrint('📡 Fetching posts for actor: $actor'); - } - - final queryParams = { - 'actor': actor, - 'limit': limit, - }; - - if (filter != null) { - queryParams['filter'] = filter; - } - - if (community != null) { - queryParams['community'] = community; - } - - if (cursor != null) { - queryParams['cursor'] = cursor; - } - - final response = await _dio.get( + }) { + return _request( + operation: 'fetch actor posts', + send: () => _dio.get( '/xrpc/social.coves.actor.getPosts', - queryParameters: queryParams, - ); - - final data = response.data; - if (data is! Map) { - throw FormatException('Expected Map but got ${data.runtimeType}'); - } - - if (kDebugMode) { - debugPrint( - '✅ Actor posts fetched: ' - '${data['feed']?.length ?? 0} posts', - ); - } - - return TimelineResponse.fromJson(data); - } on DioException catch (e) { - _handleDioException(e, 'actor posts'); // Never returns - always throws - } on FormatException { - rethrow; - } on Exception catch (e) { - if (kDebugMode) { - debugPrint('❌ Error parsing actor posts response: $e'); - } - throw ApiException('Failed to parse server response', originalError: e); - } + queryParameters: { + 'actor': actor, + 'limit': limit, + if (filter != null) 'filter': filter, + if (community != null) 'community': community, + if (cursor != null) 'cursor': cursor, + }, + ), + parse: (data) => TimelineResponse.fromJson(_asJsonMap(data)), + ); } /// Get comments by a specific actor @@ -965,53 +602,20 @@ class CovesApiService { String? community, int limit = 50, String? cursor, - }) async { - try { - if (kDebugMode) { - debugPrint('📡 Fetching comments for actor: $actor'); - } - - final queryParams = { - 'actor': actor, - 'limit': limit, - }; - - if (community != null) { - queryParams['community'] = community; - } - - if (cursor != null) { - queryParams['cursor'] = cursor; - } - - final response = await _dio.get( + }) { + return _request( + operation: 'fetch actor comments', + send: () => _dio.get( '/xrpc/social.coves.actor.getComments', - queryParameters: queryParams, - ); - - final data = response.data; - if (data is! Map) { - throw FormatException('Expected Map but got ${data.runtimeType}'); - } - - if (kDebugMode) { - debugPrint( - '✅ Actor comments fetched: ' - '${data['comments']?.length ?? 0} comments', - ); - } - - return ActorCommentsResponse.fromJson(data); - } on DioException catch (e) { - _handleDioException(e, 'actor comments'); - } on FormatException { - rethrow; - } on Exception catch (e) { - if (kDebugMode) { - debugPrint('❌ Error parsing actor comments response: $e'); - } - throw ApiException('Failed to parse server response', originalError: e); - } + queryParameters: { + 'actor': actor, + 'limit': limit, + if (community != null) 'community': community, + if (cursor != null) 'cursor': cursor, + }, + ), + parse: (data) => ActorCommentsResponse.fromJson(_asJsonMap(data)), + ); } /// Subscribe to a community @@ -1023,35 +627,21 @@ class CovesApiService { /// - [community]: Community DID or handle (required) /// /// Returns the subscription URI on success. - Future subscribeToCommunity({required String community}) async { - try { - if (kDebugMode) { - debugPrint('📡 Subscribing to community: $community'); - } - - final response = await _dio.post( + Future subscribeToCommunity({required String community}) { + return _request( + operation: 'subscribe to community', + send: () => _dio.post( '/xrpc/social.coves.community.subscribe', data: {'community': community}, - ); - - if (kDebugMode) { - debugPrint('✅ Subscribed to community: $community'); - } - - final data = response.data as Map; - final uri = data['uri'] as String?; - if (uri == null || uri.isEmpty) { - throw ApiException('Server returned invalid subscription response'); - } - return uri; - } on DioException catch (e) { - _handleDioException(e, 'subscribe to community'); - } catch (e) { - if (kDebugMode) { - debugPrint('❌ Error subscribing to community: $e'); - } - throw ApiException('Failed to subscribe to community', originalError: e); - } + ), + parse: (data) { + final uri = _asJsonMap(data)['uri'] as String?; + if (uri == null || uri.isEmpty) { + throw ApiException('Server returned invalid subscription response'); + } + return uri; + }, + ); } /// Unsubscribe from a community @@ -1061,31 +651,15 @@ class CovesApiService { /// /// Parameters: /// - [community]: Community DID or handle (required) - Future unsubscribeFromCommunity({required String community}) async { - try { - if (kDebugMode) { - debugPrint('📡 Unsubscribing from community: $community'); - } - - await _dio.post( + Future unsubscribeFromCommunity({required String community}) { + return _request( + operation: 'unsubscribe from community', + send: () => _dio.post( '/xrpc/social.coves.community.unsubscribe', data: {'community': community}, - ); - - if (kDebugMode) { - debugPrint('✅ Unsubscribed from community: $community'); - } - } on DioException catch (e) { - _handleDioException(e, 'unsubscribe from community'); - } catch (e) { - if (kDebugMode) { - debugPrint('❌ Error unsubscribing from community: $e'); - } - throw ApiException( - 'Failed to unsubscribe from community', - originalError: e, - ); - } + ), + parse: (_) {}, + ); } /// Block a user by DID. Returns the block record URI. @@ -1127,40 +701,23 @@ class CovesApiService { required String didLabel, required String endpoint, required String dataKey, - }) async { + }) { if (did.isEmpty || !did.startsWith('did:')) { throw ApiException('Invalid $didLabel DID'); } - try { - if (kDebugMode) { - debugPrint('📡 Blocking $didLabel: $did'); - } - - final response = await _dio.post( - endpoint, - data: {dataKey: did}, - ); - - if (kDebugMode) { - debugPrint('✅ Blocked $didLabel: $did'); - } - - final data = response.data as Map; - final recordUri = - (data['block'] as Map)['recordUri'] as String?; - if (recordUri == null || recordUri.isEmpty) { - throw ApiException('Server returned invalid block response'); - } - return recordUri; - } on DioException catch (e) { - _handleDioException(e, 'block $didLabel'); - } catch (e) { - if (e is ApiException) rethrow; - if (kDebugMode) { - debugPrint('❌ Error blocking $didLabel: $e'); - } - throw ApiException('Failed to block $didLabel', originalError: e); - } + return _request( + operation: 'block $didLabel', + send: () => _dio.post(endpoint, data: {dataKey: did}), + parse: (data) { + final recordUri = + (_asJsonMap(data)['block'] as Map)['recordUri'] + as String?; + if (recordUri == null || recordUri.isEmpty) { + throw ApiException('Server returned invalid block response'); + } + return recordUri; + }, + ); } /// Shared helper for unblock operations. @@ -1169,32 +726,15 @@ class CovesApiService { required String didLabel, required String endpoint, required String dataKey, - }) async { + }) { if (did.isEmpty || !did.startsWith('did:')) { throw ApiException('Invalid $didLabel DID'); } - try { - if (kDebugMode) { - debugPrint('📡 Unblocking $didLabel: $did'); - } - - await _dio.post( - endpoint, - data: {dataKey: did}, - ); - - if (kDebugMode) { - debugPrint('✅ Unblocked $didLabel: $did'); - } - } on DioException catch (e) { - _handleDioException(e, 'unblock $didLabel'); - } catch (e) { - if (e is ApiException) rethrow; - if (kDebugMode) { - debugPrint('❌ Error unblocking $didLabel: $e'); - } - throw ApiException('Failed to unblock $didLabel', originalError: e); - } + return _request( + operation: 'unblock $didLabel', + send: () => _dio.post(endpoint, data: {dataKey: did}), + parse: (_) {}, + ); } /// Update a community's profile (e.g., avatar) @@ -1218,7 +758,7 @@ class CovesApiService { required String communityDid, required Uint8List imageBytes, required String mimeType, - }) async { + }) { // Validate image size (max 1 MB) const maxSizeBytes = 1024 * 1024; // 1 MB if (imageBytes.length > maxSizeBytes) { @@ -1227,159 +767,27 @@ class CovesApiService { '(${(imageBytes.length / 1024 / 1024).toStringAsFixed(2)} MB)', ); } + _validateImageMimeType(mimeType); - // Validate MIME type - const supportedMimeTypes = {'image/jpeg', 'image/png', 'image/webp'}; - if (!supportedMimeTypes.contains(mimeType)) { - throw ApiException( - 'Unsupported image type: $mimeType. ' - 'Supported types: ${supportedMimeTypes.join(', ')}', - ); - } - - try { - if (kDebugMode) { - debugPrint( - '📡 Updating community avatar: $communityDid ' - '(${imageBytes.length} bytes, $mimeType)', - ); - } - - // Encode image bytes to base64 - final avatarBlob = base64Encode(imageBytes); - - final requestBody = { - 'communityDid': communityDid, - 'avatarBlob': avatarBlob, - 'avatarMimeType': mimeType, - }; - - final response = await _dio.post( + return _request( + operation: 'update community', + send: () => _dio.post( '/xrpc/social.coves.community.update', - data: requestBody, - ); - - if (kDebugMode) { - debugPrint('✅ Community avatar updated successfully'); - } - - return CreateCommunityResponse.fromJson( - response.data as Map, - ); - } on DioException catch (e) { - _handleDioException(e, 'update community'); - } catch (e) { - if (e is ApiException) { - rethrow; - } - if (kDebugMode) { - debugPrint('❌ Error updating community: $e'); - } - throw ApiException('Failed to update community', originalError: e); - } - } - - /// Handle Dio exceptions with specific error types - /// - /// Converts generic DioException into specific typed exceptions - /// for better error handling throughout the app. - Never _handleDioException(DioException e, String operation) { - if (kDebugMode) { - debugPrint('❌ Failed to fetch $operation: ${e.message}'); - if (e.response != null) { - debugPrint(' Status: ${e.response?.statusCode}'); - // Response data can echo credentials — redact before printing - debugPrint(redactBearerTokens(' Data: ${e.response?.data}')); - } - } - - // Handle specific HTTP status codes - if (e.response != null) { - final statusCode = e.response!.statusCode; - // Handle both JSON error responses and plain text responses - String? message; - final data = e.response!.data; - if (data is Map) { - message = data['error'] as String? ?? data['message'] as String?; - } else if (data is String && data.isNotEmpty) { - message = data; - } - - if (statusCode != null) { - if (statusCode == 401) { - throw AuthenticationException( - message?.toString() ?? - 'Authentication failed. Token expired or invalid', - originalError: e, - ); - } else if (statusCode == 404) { - throw NotFoundException( - message?.toString() ?? - 'Resource not found. PDS or content may not exist', - originalError: e, - ); - } else if (statusCode >= 500) { - throw ServerException( - message?.toString() ?? 'Server error. Please try again later', - statusCode: statusCode, - originalError: e, - ); - } else { - // Other HTTP errors - throw ApiException( - message?.toString() ?? 'Request failed: ${e.message}', - statusCode: statusCode, - originalError: e, - ); - } - } else { - // No status code in response - throw ApiException( - message?.toString() ?? 'Request failed: ${e.message}', - originalError: e, - ); - } - } - - // Handle network-level errors (no response from server) - switch (e.type) { - case DioExceptionType.connectionTimeout: - case DioExceptionType.sendTimeout: - case DioExceptionType.receiveTimeout: - throw NetworkException( - 'Connection timeout. Please check your internet connection', - originalError: e, - ); - case DioExceptionType.connectionError: - // Could be federation issue if it's a PDS connection failure - if (e.message?.contains('Failed host lookup') ?? false) { - throw FederationException( - 'Failed to connect to PDS. Server may be unreachable', - originalError: e, - ); - } - throw NetworkException( - 'Network error. Please check your internet connection', - originalError: e, - ); - case DioExceptionType.badResponse: - // Already handled above by response status code check - throw ApiException( - 'Bad response from server: ${e.message}', - statusCode: e.response?.statusCode, - originalError: e, - ); - case DioExceptionType.cancel: - throw ApiException('Request cancelled', originalError: e); - default: - throw ApiException('Unknown error: ${e.message}', originalError: e); - } + data: { + 'communityDid': communityDid, + 'avatarBlob': base64Encode(imageBytes), + 'avatarMimeType': mimeType, + }, + ), + parse: (data) => CreateCommunityResponse.fromJson(_asJsonMap(data)), + ); } /// Update the authenticated user's profile /// /// All parameters are optional - only non-null values will be sent to - /// the API. This allows updating individual fields without affecting others. + /// the API. This allows updating individual fields without affecting + /// others. /// /// Parameters: /// - [displayName]: New display name (optional, max 64 chars) @@ -1402,7 +810,7 @@ class CovesApiService { String? avatarMimeType, Uint8List? bannerBytes, String? bannerMimeType, - }) async { + }) { // Validate avatar if provided if (avatarBytes != null) { if (avatarMimeType == null) { @@ -1433,61 +841,25 @@ class CovesApiService { _validateImageMimeType(bannerMimeType); } - try { - if (kDebugMode) { - debugPrint( - '📡 Updating profile: ' - 'displayName=${displayName != null}, ' - 'bio=${bio != null}, ' - 'avatar=${avatarBytes != null ? "${avatarBytes.length} bytes" : "null"}, ' - 'banner=${bannerBytes != null ? "${bannerBytes.length} bytes" : "null"}', - ); - } - - // Build request body with only non-null fields - final requestBody = {}; - - if (displayName != null) { - requestBody['displayName'] = displayName; - } - - if (bio != null) { - requestBody['bio'] = bio; - } - - if (avatarBytes != null) { - requestBody['avatarBlob'] = base64Encode(avatarBytes); - requestBody['avatarMimeType'] = avatarMimeType; - } - - if (bannerBytes != null) { - requestBody['bannerBlob'] = base64Encode(bannerBytes); - requestBody['bannerMimeType'] = bannerMimeType; - } - - final response = await _dio.post( + return _request( + operation: 'update profile', + send: () => _dio.post( '/xrpc/social.coves.actor.updateProfile', - data: requestBody, - ); - - if (kDebugMode) { - debugPrint('✅ Profile updated successfully'); - } - - return UpdateProfileResponse.fromJson( - response.data as Map, - ); - } on DioException catch (e) { - _handleDioException(e, 'update profile'); - } catch (e) { - if (e is ApiException) { - rethrow; - } - if (kDebugMode) { - debugPrint('❌ Error updating profile: $e'); - } - throw ApiException('Failed to update profile', originalError: e); - } + data: { + if (displayName != null) 'displayName': displayName, + if (bio != null) 'bio': bio, + if (avatarBytes != null) ...{ + 'avatarBlob': base64Encode(avatarBytes), + 'avatarMimeType': avatarMimeType, + }, + if (bannerBytes != null) ...{ + 'bannerBlob': base64Encode(bannerBytes), + 'bannerMimeType': bannerMimeType, + }, + }, + ), + parse: (data) => UpdateProfileResponse.fromJson(_asJsonMap(data)), + ); } /// Validate image MIME type for profile images @@ -1521,7 +893,7 @@ class CovesApiService { required String targetUri, required String reason, String? explanation, - }) async { + }) { // Validate inputs before making API call const validReasons = { 'spam', @@ -1541,49 +913,30 @@ class CovesApiService { } if (explanation != null && explanation.length > 1000) { - throw ApiException('Explanation exceeds maximum length of 1000 characters'); + throw ApiException( + 'Explanation exceeds maximum length of 1000 characters', + ); } - try { - if (kDebugMode) { - debugPrint('🚨 Submitting report for: $targetUri (reason: $reason)'); - } - - final requestBody = { - 'targetUri': targetUri, - 'reason': reason, - }; - - if (explanation != null && explanation.isNotEmpty) { - requestBody['explanation'] = explanation; - } - - final response = await _dio.post( + return _request( + operation: 'submit report', + send: () => _dio.post( '/xrpc/social.coves.admin.submitReport', - data: requestBody, - ); - - if (kDebugMode) { - debugPrint('✅ Report submitted successfully'); - } - - final data = response.data as Map; - final reportId = data['reportId'] as int?; - if (reportId == null) { - throw ApiException('Server returned invalid report response'); - } - return reportId; - } on DioException catch (e) { - throw _handleDioException(e, 'submit report'); - } catch (e) { - if (e is ApiException) { - rethrow; - } - if (kDebugMode) { - debugPrint('❌ Error submitting report: $e'); - } - throw ApiException('Failed to submit report', originalError: e); - } + data: { + 'targetUri': targetUri, + 'reason': reason, + if (explanation != null && explanation.isNotEmpty) + 'explanation': explanation, + }, + ), + parse: (data) { + final reportId = _asJsonMap(data)['reportId'] as int?; + if (reportId == null) { + throw ApiException('Server returned invalid report response'); + } + return reportId; + }, + ); } /// Dispose resources @@ -1601,10 +954,14 @@ class UpdateProfileResponse { final cid = json['cid']; if (uri is! String || uri.isEmpty) { - throw const FormatException('UpdateProfileResponse: missing or invalid uri'); + throw const FormatException( + 'UpdateProfileResponse: missing or invalid uri', + ); } if (cid is! String || cid.isEmpty) { - throw const FormatException('UpdateProfileResponse: missing or invalid cid'); + throw const FormatException( + 'UpdateProfileResponse: missing or invalid cid', + ); } return UpdateProfileResponse(uri: uri, cid: cid); diff --git a/lib/services/log_redaction.dart b/lib/services/log_redaction.dart new file mode 100644 index 0000000..5ca6521 --- /dev/null +++ b/lib/services/log_redaction.dart @@ -0,0 +1,16 @@ +/// Bearer-token redaction for debug logs. +/// +/// Shared by every service that prints request/response data — credentials +/// must never reach the logs, even in debug builds. +library; + +/// Matches a bearer scheme (case-insensitive) followed by any run of +/// non-whitespace characters. Greedy on purpose: a charset-based match +/// would leak the tail of tokens containing characters outside the set. +final RegExp _bearerTokenPattern = RegExp(r'Bearer\s+\S+', caseSensitive: false); + +/// Replaces bearer token values with a placeholder so credentials never +/// appear in logs. +String redactBearerTokens(String line) { + return line.replaceAll(_bearerTokenPattern, 'Bearer [REDACTED]'); +} diff --git a/lib/services/vote_service.dart b/lib/services/vote_service.dart index 0d97e13..fabc2db 100644 --- a/lib/services/vote_service.dart +++ b/lib/services/vote_service.dart @@ -61,7 +61,7 @@ class VoteService { // Add shared 401 retry interceptor _dio.interceptors.add( createAuthInterceptor( - sessionGetter: sessionGetter, + tokenGetter: () async => (await sessionGetter?.call())?.token, tokenRefresher: tokenRefresher, signOutHandler: signOutHandler, serviceName: 'VoteService', @@ -160,24 +160,7 @@ class VoteService { return VoteResponse(uri: uri, cid: cid, rkey: rkey, deleted: false); } on DioException catch (e) { - if (kDebugMode) { - debugPrint('❌ Vote failed: ${e.message}'); - debugPrint(' Status: ${e.response?.statusCode}'); - debugPrint(' Data: ${e.response?.data}'); - } - - if (e.response?.statusCode == 401) { - throw AuthenticationException( - 'Authentication failed. Please sign in again.', - originalError: e, - ); - } - - throw ApiException( - 'Failed to create vote: ${e.message}', - statusCode: e.response?.statusCode, - originalError: e, - ); + throw mapDioException(e, operation: 'create vote'); } on ApiException { rethrow; } on Exception catch (e) { diff --git a/test/services/api_exceptions_test.dart b/test/services/api_exceptions_test.dart new file mode 100644 index 0000000..a145833 --- /dev/null +++ b/test/services/api_exceptions_test.dart @@ -0,0 +1,185 @@ +import 'package:coves_flutter/services/api_exceptions.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Tests for the canonical DioException → ApiException mapper. +/// +/// This is the single mapping used by CovesApiService, VoteService, and +/// CommentService — behavior asserted here holds for every endpoint. +void main() { + DioException responseError( + int statusCode, + Object? data, { + DioExceptionType type = DioExceptionType.badResponse, + }) { + return DioException( + requestOptions: RequestOptions(path: '/test'), + type: type, + response: Response( + requestOptions: RequestOptions(path: '/test'), + statusCode: statusCode, + data: data, + ), + ); + } + + DioException networkError(DioExceptionType type, {String? message}) { + return DioException( + requestOptions: RequestOptions(path: '/test'), + type: type, + message: message, + ); + } + + group('ApiException.fromDioError - responses with status codes', () { + test('maps 401 to AuthenticationException', () { + final exception = ApiException.fromDioError( + responseError(401, {'message': 'Unauthorized'}), + ); + + expect(exception, isA()); + expect(exception.statusCode, 401); + expect(exception.message, 'Unauthorized'); + }); + + test('maps 404 to NotFoundException', () { + final exception = ApiException.fromDioError( + responseError(404, {'message': 'Post not found'}), + ); + + expect(exception, isA()); + expect(exception.statusCode, 404); + expect(exception.message, 'Post not found'); + }); + + test('maps 5xx to ServerException', () { + final exception = ApiException.fromDioError( + responseError(500, {'error': 'Internal server error'}), + ); + + expect(exception, isA()); + expect(exception.statusCode, 500); + expect(exception.message, 'Internal server error'); + }); + + test('maps other status codes to plain ApiException', () { + final exception = ApiException.fromDioError( + responseError(400, {'message': 'Invalid post URI'}), + ); + + expect(exception, isA()); + expect(exception, isNot(isA())); + expect(exception.statusCode, 400); + expect(exception.message, 'Invalid post URI'); + }); + + test('prefers human-readable message over XRPC error code', () { + final exception = ApiException.fromDioError( + responseError(400, { + 'error': 'InvalidRequest', + 'message': 'title too long', + }), + ); + + expect(exception.message, 'title too long'); + }); + + test('falls back to the XRPC error code when message is absent', () { + final exception = ApiException.fromDioError( + responseError(400, {'error': 'InvalidRequest'}), + ); + + expect(exception.message, 'InvalidRequest'); + }); + + test('uses plain-text response bodies as the message', () { + final exception = ApiException.fromDioError( + responseError(502, 'upstream unavailable'), + ); + + expect(exception, isA()); + expect(exception.message, 'upstream unavailable'); + }); + + test('uses a default message when the body has none', () { + final exception = ApiException.fromDioError( + responseError(400, {}), + ); + + expect(exception.message, 'Request failed with status 400'); + }); + + test('maps by status code regardless of DioExceptionType', () { + final exception = ApiException.fromDioError( + responseError( + 401, + {'message': 'Token expired'}, + type: DioExceptionType.unknown, + ), + ); + + expect(exception, isA()); + }); + }); + + group('ApiException.fromDioError - network-level errors', () { + test('maps timeouts to NetworkException', () { + for (final type in [ + DioExceptionType.connectionTimeout, + DioExceptionType.sendTimeout, + DioExceptionType.receiveTimeout, + ]) { + final exception = ApiException.fromDioError(networkError(type)); + + expect(exception, isA()); + expect(exception.message, contains('timeout')); + } + }); + + test('maps connection errors to NetworkException', () { + final exception = ApiException.fromDioError( + networkError(DioExceptionType.connectionError), + ); + + expect(exception, isA()); + expect(exception.message, contains('Network error')); + }); + + test('maps DNS resolution failures to FederationException', () { + final exception = ApiException.fromDioError( + networkError( + DioExceptionType.connectionError, + message: 'Failed host lookup: pds.example.com', + ), + ); + + expect(exception, isA()); + }); + + test('maps bad certificates to NetworkException', () { + final exception = ApiException.fromDioError( + networkError(DioExceptionType.badCertificate), + ); + + expect(exception, isA()); + expect(exception.message, contains('certificate')); + }); + + test('maps cancelled requests to ApiException', () { + final exception = ApiException.fromDioError( + networkError(DioExceptionType.cancel), + ); + + expect(exception.message, contains('cancelled')); + }); + + test('maps unknown errors to NetworkException', () { + final exception = ApiException.fromDioError( + networkError(DioExceptionType.unknown), + ); + + expect(exception, isA()); + expect(exception.message, contains('Network error')); + }); + }); +} diff --git a/test/services/coves_api_service_redaction_test.dart b/test/services/coves_api_service_redaction_test.dart index 7c4a10d..d500309 100644 --- a/test/services/coves_api_service_redaction_test.dart +++ b/test/services/coves_api_service_redaction_test.dart @@ -1,5 +1,6 @@ import 'package:coves_flutter/services/api_exceptions.dart'; import 'package:coves_flutter/services/coves_api_service.dart'; +import 'package:coves_flutter/services/log_redaction.dart'; import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -83,31 +84,31 @@ void main() { }); }); - group('CovesApiService.redactBearerTokens', () { + group('redactBearerTokens', () { test('greedily redacts tokens with chars outside the old charset', () { expect( - CovesApiService.redactBearerTokens('Authorization: Bearer $token'), + redactBearerTokens('Authorization: Bearer $token'), 'Authorization: Bearer [REDACTED]', ); }); test('is case-insensitive', () { expect( - CovesApiService.redactBearerTokens('authorization: bearer $token'), + redactBearerTokens('authorization: bearer $token'), 'authorization: Bearer [REDACTED]', ); }); test('handles tab whitespace between scheme and token', () { expect( - CovesApiService.redactBearerTokens('Bearer\t$token trailing'), + redactBearerTokens('Bearer\t$token trailing'), 'Bearer [REDACTED] trailing', ); }); test('redacts every occurrence in a line', () { expect( - CovesApiService.redactBearerTokens('Bearer aaa!x and BEARER bbb!y'), + redactBearerTokens('Bearer aaa!x and BEARER bbb!y'), 'Bearer [REDACTED] and Bearer [REDACTED]', ); }); diff --git a/test/services/vote_service_test.dart b/test/services/vote_service_test.dart index 2ab4ffa..b0fa341 100644 --- a/test/services/vote_service_test.dart +++ b/test/services/vote_service_test.dart @@ -1,6 +1,4 @@ -import 'package:coves_flutter/services/api_exceptions.dart'; import 'package:coves_flutter/services/vote_service.dart'; -import 'package:dio/dio.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { @@ -30,151 +28,7 @@ void main() { }); }); - group('API Exception handling', () { - test('should throw ApiException on Dio network error', () { - final dioError = DioException( - requestOptions: RequestOptions(path: '/test'), - type: DioExceptionType.connectionError, - ); - - final exception = ApiException.fromDioError(dioError); - - expect(exception, isA()); - expect(exception.message, contains('Connection failed')); - }); - - test('should throw ApiException on Dio timeout', () { - final dioError = DioException( - requestOptions: RequestOptions(path: '/test'), - type: DioExceptionType.connectionTimeout, - ); - - final exception = ApiException.fromDioError(dioError); - - expect(exception, isA()); - expect(exception.message, contains('timeout')); - }); - - test('should throw AuthenticationException on 401 response', () { - final dioError = DioException( - requestOptions: RequestOptions(path: '/test'), - type: DioExceptionType.badResponse, - response: Response( - requestOptions: RequestOptions(path: '/test'), - statusCode: 401, - data: {'message': 'Unauthorized'}, - ), - ); - - final exception = ApiException.fromDioError(dioError); - - expect(exception, isA()); - expect(exception.statusCode, 401); - expect(exception.message, 'Unauthorized'); - }); - - test('should throw NotFoundException on 404 response', () { - final dioError = DioException( - requestOptions: RequestOptions(path: '/test'), - type: DioExceptionType.badResponse, - response: Response( - requestOptions: RequestOptions(path: '/test'), - statusCode: 404, - data: {'message': 'Post not found'}, - ), - ); - - final exception = ApiException.fromDioError(dioError); - - expect(exception, isA()); - expect(exception.statusCode, 404); - expect(exception.message, 'Post not found'); - }); - - test('should throw ServerException on 500 response', () { - final dioError = DioException( - requestOptions: RequestOptions(path: '/test'), - type: DioExceptionType.badResponse, - response: Response( - requestOptions: RequestOptions(path: '/test'), - statusCode: 500, - data: {'error': 'Internal server error'}, - ), - ); - - final exception = ApiException.fromDioError(dioError); - - expect(exception, isA()); - expect(exception.statusCode, 500); - expect(exception.message, 'Internal server error'); - }); - - test('should extract error message from response data', () { - final dioError = DioException( - requestOptions: RequestOptions(path: '/test'), - type: DioExceptionType.badResponse, - response: Response( - requestOptions: RequestOptions(path: '/test'), - statusCode: 400, - data: {'message': 'Invalid post URI'}, - ), - ); - - final exception = ApiException.fromDioError(dioError); - - expect(exception.message, 'Invalid post URI'); - expect(exception.statusCode, 400); - }); - - test('should use default message if no error message in response', () { - final dioError = DioException( - requestOptions: RequestOptions(path: '/test'), - type: DioExceptionType.badResponse, - response: Response( - requestOptions: RequestOptions(path: '/test'), - statusCode: 400, - data: {}, - ), - ); - - final exception = ApiException.fromDioError(dioError); - - expect(exception.message, 'Server error'); - }); - - test('should handle cancelled requests', () { - final dioError = DioException( - requestOptions: RequestOptions(path: '/test'), - type: DioExceptionType.cancel, - ); - - final exception = ApiException.fromDioError(dioError); - - expect(exception.message, contains('cancelled')); - }); - - test('should handle bad certificate errors', () { - final dioError = DioException( - requestOptions: RequestOptions(path: '/test'), - type: DioExceptionType.badCertificate, - ); - - final exception = ApiException.fromDioError(dioError); - - expect(exception, isA()); - expect(exception.message, contains('certificate')); - }); - - test('should handle unknown errors', () { - final dioError = DioException( - requestOptions: RequestOptions(path: '/test'), - ); - - final exception = ApiException.fromDioError(dioError); - - expect(exception, isA()); - expect(exception.message, contains('Network error')); - }); - }); + // DioException → ApiException mapping is covered by + // api_exceptions_test.dart — VoteService delegates to the shared mapper. }); }