diff --git a/README.md b/README.md index 0f802a8a..82356bb8 100644 --- a/README.md +++ b/README.md @@ -291,6 +291,37 @@ client.getTask('2').then((it) { }); ``` +#### Centralized Error Handling + +Instead of adding `.catchError()` to every API call, you can provide a centralized error handler when creating the client: + +```dart +void handleError(error) { + // Handle all API errors in one place + if (error is DioException) { + final res = error.response; + logger.e('API Error: ${res?.statusCode} -> ${res?.statusMessage}'); + } else { + logger.e('Unexpected error: $error'); + } +} + +// Create client with error handler +final client = RestClient(dio, onError: handleError); + +// All API calls will automatically use the error handler +client.getTask('2').then((it) { + logger.i(it); +}); +// No need to add .catchError() - errors are automatically handled! +``` + +This approach: +- Reduces boilerplate code by eliminating repetitive `.catchError()` calls +- Provides a single place to handle errors for all API methods +- Works with all exceptions, including `DioException`, data conversion errors, and `SocketException` +- Can be shared across multiple API clients + ### Relative API baseUrl If you want to use a relative `baseUrl` value in the `RestApi` annotation of the `RestClient`, you need to specify a `baseUrl` in `dio.options.baseUrl`. diff --git a/example/lib/example.dart b/example/lib/example.dart index fb578d7d..5fa664c4 100644 --- a/example/lib/example.dart +++ b/example/lib/example.dart @@ -8,12 +8,26 @@ import 'package:retrofit_example/api_result.dart'; part 'example.g.dart'; +/// Example REST API client +/// +/// Usage with centralized error handling: +/// ```dart +/// void handleError(error) { +/// if (error is DioException) { +/// print('API Error: ${error.response?.statusCode}'); +/// } +/// } +/// +/// final client = RestClient(dio, onError: handleError); +/// // All API calls will automatically handle errors through handleError +/// ``` @RestApi(baseUrl: 'https://5d42a6e2bc64f90014a56ca0.mockapi.io/api/v1/') abstract class RestClient { factory RestClient( Dio dio, { String? baseUrl, ParseErrorLogger? errorLogger, + Function? onError, }) = RestClientYmlp; @GET('/tasks/{id}') diff --git a/generator/lib/src/generator.dart b/generator/lib/src/generator.dart index c09291ea..db05915a 100644 --- a/generator/lib/src/generator.dart +++ b/generator/lib/src/generator.dart @@ -61,6 +61,7 @@ class RetrofitGenerator extends GeneratorForAnnotation { static const _baseUrlVar = 'baseUrl'; static const _errorLoggerVar = 'errorLogger'; + static const _onErrorVar = 'onError'; static const _queryParamsVar = 'queryParameters'; static const _optionsVar = '_options'; static const _localHeadersVar = '_headers'; @@ -150,6 +151,7 @@ class RetrofitGenerator extends GeneratorForAnnotation { _buildDioField(), _buildBaseUrlField(baseUrl), _buildErrorLoggerFiled(), + _buildOnErrorField(), ]) ..constructors.addAll( annotateClassConsts.map( @@ -202,6 +204,14 @@ class RetrofitGenerator extends GeneratorForAnnotation { ..modifier = FieldModifier.final$; }); + /// Builds the onError field. + Field _buildOnErrorField() => Field((m) { + m + ..name = _onErrorVar + ..type = refer('Function?') + ..modifier = FieldModifier.final$; + }); + /// Generates the constructor. Constructor _generateConstructor( String? url, { @@ -227,6 +237,12 @@ class RetrofitGenerator extends GeneratorForAnnotation { ..name = _errorLoggerVar ..toThis = true, ), + Parameter( + (p) => p + ..named = true + ..name = _onErrorVar + ..toThis = true, + ), ]); if (superClassConst != null) { var superConstName = 'super'; @@ -375,7 +391,7 @@ class RetrofitGenerator extends GeneratorForAnnotation { if (method.isAbstract) { methods.add(_generateApiCallMethod(method, instantiatedCallAdapter)!); } - if (callAdapter != null) { + if (callAdapter != null && instantiatedCallAdapter != null) { methods.add( _generateAdapterMethod( method, @@ -391,9 +407,18 @@ class RetrofitGenerator extends GeneratorForAnnotation { /// Generates a method implementation wrapped by CallAdapter. Method _generateAdapterMethod( MethodElement2 m, - InterfaceType? callAdapter, + InterfaceType callAdapter, String resultType, ) { + // Get the adapter class name - should never be null at this point + final adapterName = callAdapter.element3.name3; + if (adapterName == null) { + throw InvalidGenerationSourceError( + 'CallAdapter class must have a valid name', + element: m, + ); + } + return Method((methodBuilder) { methodBuilder.returns = refer( _displayString(m.returnType, withNullability: true), @@ -422,11 +447,19 @@ class RetrofitGenerator extends GeneratorForAnnotation { } final args = '${positionalArgs.map((e) => '$e,').join()} ${namedArgs.map((e) => '$e,').join()}'; - methodBuilder.body = Code(''' - return ${callAdapter?.element3.name3}<$resultType>().adapt( - () => _${m.displayName}($args), - ); - '''); + final adaptedCall = '$adapterName<$resultType>().adapt(() => _${m.displayName}($args))'; + + // Only wrap with catchError if onError exists and the adapted return type is a Future + final adaptedReturnType = callAdapter.superclass?.typeArguments.lastOrNull as InterfaceType?; + final shouldWrapWithCatchError = _isDartTypeFuture(adaptedReturnType); + + if (shouldWrapWithCatchError) { + methodBuilder.body = Code(''' + return $_onErrorVar != null ? $adaptedCall.catchError($_onErrorVar) : $adaptedCall; + '''); + } else { + methodBuilder.body = Code('return $adaptedCall;'); + } }); } @@ -832,6 +865,15 @@ class RetrofitGenerator extends GeneratorForAnnotation { /// Checks if the return type is Future. bool _isReturnTypeFuture(String type) => type.startsWith('Future<'); + /// Checks if a DartType represents a Future. + /// More reliable than string matching as it uses the analyzer's type system. + bool _isDartTypeFuture(DartType? type) { + if (type == null) { + return false; + } + return type.isDartAsyncFuture; + } + /// Generates the HTTP request code block. Code _generateRequest( MethodElement2 m, @@ -845,9 +887,7 @@ class RetrofitGenerator extends GeneratorForAnnotation { final callAdapterOriginalReturnType = callAdapter.superclass?.typeArguments.firstOrNull as InterfaceType?; returnAsyncWrapper = - _isReturnTypeFuture( - callAdapterOriginalReturnType?.getDisplayString() ?? '', - ) + _isDartTypeFuture(callAdapterOriginalReturnType) ? 'return' : 'yield'; } @@ -982,18 +1022,32 @@ class RetrofitGenerator extends GeneratorForAnnotation { : wrappedReturnType; if (returnType == null || 'void' == returnType.toString()) { if (isWrappedWithHttpResponseWrapper) { - blocks - ..add( + if (returnAsyncWrapper == 'return') { + blocks.add( refer( 'final $_resultVar = await $_dioVar.fetch', ).call([options], {}, [refer('void')]).statement, - ) - ..add( + ); + blocks.add( Code(''' final httpResponse = HttpResponse(null, $_resultVar); -$returnAsyncWrapper httpResponse; +return $_onErrorVar != null ? Future.value(httpResponse).catchError($_onErrorVar) : Future.value(httpResponse); '''), ); + } else { + blocks + ..add( + refer( + 'final $_resultVar = await $_dioVar.fetch', + ).call([options], {}, [refer('void')]).statement, + ) + ..add( + Code(''' +final httpResponse = HttpResponse(null, $_resultVar); +$returnAsyncWrapper httpResponse; +'''), + ); + } } else { blocks.add( refer( @@ -1419,14 +1473,27 @@ You should create a new class to encapsulate the response. } } if (isWrappedWithHttpResponseWrapper) { - blocks.add( - Code(''' + if (returnAsyncWrapper == 'return') { + blocks.add( + Code(''' +final httpResponse = HttpResponse($_valueVar, $_resultVar); +return $_onErrorVar != null ? Future.value(httpResponse).catchError($_onErrorVar) : Future.value(httpResponse); +'''), + ); + } else { + blocks.add( + Code(''' final httpResponse = HttpResponse($_valueVar, $_resultVar); $returnAsyncWrapper httpResponse; '''), - ); + ); + } } else { - blocks.add(Code('$returnAsyncWrapper $_valueVar;')); + if (returnAsyncWrapper == 'return') { + blocks.add(Code('return $_onErrorVar != null ? Future.value($_valueVar).catchError($_onErrorVar) : Future.value($_valueVar);')); + } else { + blocks.add(Code('$returnAsyncWrapper $_valueVar;')); + } } } diff --git a/generator/test/src/generator_test_src.dart b/generator/test/src/generator_test_src.dart index 6412e1c6..13155785 100644 --- a/generator/test/src/generator_test_src.dart +++ b/generator/test/src/generator_test_src.dart @@ -18,7 +18,7 @@ class MockCallAdapter1 extends CallAdapter, Future>> { @ShouldGenerate(''' @override Future>> getUser() { - return MockCallAdapter1>().adapt(() => _getUser()); + return onError != null ? MockCallAdapter1>().adapt(() => _getUser()).catchError(onError) : MockCallAdapter1>().adapt(() => _getUser()); } ''', contains: true) @RestApi() @@ -41,7 +41,7 @@ class MockCallAdapter2 @ShouldGenerate(''' @override Future> getUser() { - return MockCallAdapter2().adapt(() => _getUser()); + return onError != null ? MockCallAdapter2().adapt(() => _getUser()).catchError(onError) : MockCallAdapter2().adapt(() => _getUser()); } ''', contains: true) @RestApi() @@ -84,7 +84,7 @@ class MockCallAdapter3 extends CallAdapter, Flow> { errorLogger?.logError(e, s, _options); rethrow; } - return _value; + return onError != null ? Future.value(_value).catchError(onError) : Future.value(_value); } ''', contains: true) @RestApi() @@ -549,7 +549,7 @@ abstract class UploadFileInfoPartTest { errorLogger?.logError(e, s, _options); rethrow; } - return _value; + return onError != null ? Future.value(_value).catchError(onError) : Future.value(_value); ''', contains: true) @RestApi(baseUrl: 'https://httpbin.org/') abstract class GenericCast { @@ -565,7 +565,7 @@ abstract class GenericCast { errorLogger?.logError(e, s, _options); rethrow; } - return _value; + return onError != null ? Future.value(_value).catchError(onError) : Future.value(_value); ''', contains: true) @RestApi(baseUrl: 'https://httpbin.org/') abstract class NullableGenericCast { @@ -642,7 +642,7 @@ enum FromJsonEnum { errorLogger?.logError(e, s, _options); rethrow; } - return _value; + return onError != null ? Future.value(_value).catchError(onError) : Future.value(_value); ''', contains: true) @RestApi() abstract class EnumFromJsonReturnType { @@ -745,7 +745,7 @@ Map serializeUser(User object) => object.toJson(); errorLogger?.logError(e, s, _options); rethrow; } - return _value; + return onError != null ? Future.value(_value).catchError(onError) : Future.value(_value); ''', contains: true) @RestApi(baseUrl: 'https://httpbin.org/') abstract class GenericCastBasicType { @@ -761,7 +761,7 @@ abstract class GenericCastBasicType { errorLogger?.logError(e, s, _options); rethrow; } - return _value; + return onError != null ? Future.value(_value).catchError(onError) : Future.value(_value); ''', contains: true) @RestApi(baseUrl: 'https://httpbin.org/') abstract class NullableGenericCastBasicType { @@ -986,7 +986,7 @@ abstract class TestCustomObjectBody { errorLogger?.logError(e, s, _options); rethrow; } - return _value; + return onError != null ? Future.value(_value).catchError(onError) : Future.value(_value); } ''', contains: true) @RestApi(baseUrl: 'https://httpbin.org/') @@ -1010,7 +1010,7 @@ abstract class TestMapBody { errorLogger?.logError(e, s, _options); rethrow; } - return _value; + return onError != null ? Future.value(_value).catchError(onError) : Future.value(_value); } ''', contains: true) @RestApi(baseUrl: 'https://httpbin.org/') @@ -1029,7 +1029,7 @@ abstract class NullableTestMapBody { errorLogger?.logError(e, s, _options); rethrow; } - return _value; + return onError != null ? Future.value(_value).catchError(onError) : Future.value(_value); } ''', contains: true) @RestApi(baseUrl: 'https://httpbin.org/') @@ -1048,7 +1048,7 @@ abstract class TestMapBody2 { errorLogger?.logError(e, s, _options); rethrow; } - return _value; + return onError != null ? Future.value(_value).catchError(onError) : Future.value(_value); } ''', contains: true) @RestApi(baseUrl: 'https://httpbin.org/') @@ -1065,7 +1065,7 @@ abstract class NullableTestMapBody2 { errorLogger?.logError(e, s, _options); rethrow; } - return _value; + return onError != null ? Future.value(_value).catchError(onError) : Future.value(_value); ''', contains: true) @RestApi(baseUrl: 'https://httpbin.org/') abstract class TestBasicListString { @@ -1081,7 +1081,7 @@ abstract class TestBasicListString { errorLogger?.logError(e, s, _options); rethrow; } - return _value; + return onError != null ? Future.value(_value).catchError(onError) : Future.value(_value); ''', contains: true) @RestApi(baseUrl: 'https://httpbin.org/') abstract class NullableTestBasicListString { @@ -1097,7 +1097,7 @@ abstract class NullableTestBasicListString { errorLogger?.logError(e, s, _options); rethrow; } - return _value; + return onError != null ? Future.value(_value).catchError(onError) : Future.value(_value); ''', contains: true) @RestApi(baseUrl: 'https://httpbin.org/') abstract class TestBasicListBool { @@ -1113,7 +1113,7 @@ abstract class TestBasicListBool { errorLogger?.logError(e, s, _options); rethrow; } - return _value; + return onError != null ? Future.value(_value).catchError(onError) : Future.value(_value); ''', contains: true) @RestApi(baseUrl: 'https://httpbin.org/') abstract class NullableTestBasicListBool { @@ -1129,7 +1129,7 @@ abstract class NullableTestBasicListBool { errorLogger?.logError(e, s, _options); rethrow; } - return _value; + return onError != null ? Future.value(_value).catchError(onError) : Future.value(_value); ''', contains: true) @RestApi(baseUrl: 'https://httpbin.org/') abstract class TestBasicListInt { @@ -1145,7 +1145,7 @@ abstract class TestBasicListInt { errorLogger?.logError(e, s, _options); rethrow; } - return _value; + return onError != null ? Future.value(_value).catchError(onError) : Future.value(_value); ''', contains: true) @RestApi(baseUrl: 'https://httpbin.org/') abstract class NullableTestBasicListInt { @@ -1161,7 +1161,7 @@ abstract class NullableTestBasicListInt { errorLogger?.logError(e, s, _options); rethrow; } - return _value; + return onError != null ? Future.value(_value).catchError(onError) : Future.value(_value); ''', contains: true) @RestApi(baseUrl: 'https://httpbin.org/') abstract class TestBasicListDouble { @@ -1177,7 +1177,7 @@ abstract class TestBasicListDouble { errorLogger?.logError(e, s, _options); rethrow; } - return _value; + return onError != null ? Future.value(_value).catchError(onError) : Future.value(_value); ''', contains: true) @RestApi(baseUrl: 'https://httpbin.org/') abstract class NullableTestBasicListDouble { @@ -1260,7 +1260,7 @@ abstract class TestHttpResponseObject { rethrow; } final httpResponse = HttpResponse(_value, _result); - return httpResponse; + return onError != null ? Future.value(httpResponse).catchError(onError) : Future.value(httpResponse); ''', contains: true) @RestApi() abstract class TestHttpResponseArray { @@ -2577,6 +2577,43 @@ abstract class UseResultForVoid { Future someGet(); } +// Test onError parameter +@ShouldGenerate( + ''' + final Function? onError; + ''', + contains: true, +) +@RestApi() +abstract class OnErrorField { + @GET('/test') + Future getData(); +} + +@ShouldGenerate( + ''' + _TestOnErrorConstructor(this._dio, {this.baseUrl, this.errorLogger, this.onError}); + ''', + contains: true, +) +@RestApi() +abstract class TestOnErrorConstructor { + @GET('/test') + Future getData(); +} + +@ShouldGenerate( + ''' + return onError != null ? Future.value(_value).catchError(onError) : Future.value(_value); + ''', + contains: true, +) +@RestApi() +abstract class TestOnErrorWrapping { + @GET('/test') + Future getData(); +} + @ShouldGenerate(''' final _headers = { r'User-Agent': 'MyApp/1.0.0',