From 85242451d2817ef2576e1b7628ea0fdb7b1fb003 Mon Sep 17 00:00:00 2001 From: Bretton Date: Thu, 6 Aug 2026 00:00:41 -0700 Subject: [PATCH] refactor(services): unify API error handling behind one mapper and request helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The API service had grown to 1,619 lines of per-endpoint boilerplate, and the error taxonomy had forked three ways: coves_api_service's _handleDioException, hand-rolled DioException switches in vote_service and comment_service, and a dead ApiException.fromDioError that only tests used and that disagreed with all of them. Worst, the 401-refresh interceptor existed twice: the shared copy in auth_interceptor.dart logged error response bodies unredacted while the inline copy in coves_api_service redacted them because bodies can echo credentials. - ApiException.fromDioError is now the single canonical DioException mapper (production _handleDioException semantics: JSON/plain-text message extraction, 401/404/5xx typing, timeout/DNS/connection typing), preferring the human-readable XRPC `message` over the `error` code. mapDioException wraps it with redacted debug logging. All three services delegate to it; _handleDioException is deleted. - createAuthInterceptor is now token-based and the only 401-refresh implementation: CovesApiService's ~145-line inline copy is deleted, the refresh-endpoint sign-out guard is preserved for all callers, and the shared copy now redacts logged response bodies (closing the credential leak). - New _request() helper runs every CovesApiService endpoint through one try/catch, so endpoints are just query/body construction plus a parse callback (file drops from 1,619 to 970 lines). This also fixes endpoints that swallowed their own ApiExceptions into generic messages (e.g. subscribeToCommunity). Parse failures uniformly become ApiException('Failed to parse server response') — services no longer leak FormatException. - redactBearerTokens moves to log_redaction.dart so the interceptor and exception mapper can share it. - The mapper tests move from vote_service_test.dart to a dedicated api_exceptions_test.dart covering the canonical semantics. Co-Authored-By: Claude Fable 5 --- lib/services/api_exceptions.dart | 120 +- lib/services/auth_interceptor.dart | 50 +- lib/services/comment_service.dart | 61 +- lib/services/coves_api_service.dart | 1377 +++++------------ lib/services/log_redaction.dart | 16 + lib/services/vote_service.dart | 21 +- test/services/api_exceptions_test.dart | 185 +++ .../coves_api_service_redaction_test.dart | 11 +- test/services/vote_service_test.dart | 150 +- 9 files changed, 714 insertions(+), 1277 deletions(-) create mode 100644 lib/services/log_redaction.dart create mode 100644 test/services/api_exceptions_test.dart 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. }); } -- 2.51.2