diff --git a/README.md b/README.md index 9c9f56ca..0743e4c3 100644 --- a/README.md +++ b/README.md @@ -242,6 +242,40 @@ dio.options.baseUrl = 'https://5d42a6e2bc64f90014a56ca0.mockapi.io/api/v1'; final client = RestClient(dio); ``` +### Call Adapter + +This feature allows you to adapt the return type of a network call from one type to another. + +For example: +Future → Future> + +This feature provides flexibility in handling API responses, enabling better integration with custom response wrappers or error handling libraries. + +The CallAdapter takes the original return type R and transforms it into a new type T. This is particularly useful when working with response wrappers like Either, Result, or ApiResponse. + +Below is an example using a custom CallAdapter with a Result wrapper: +```dart + class MyCallAdapter extends CallAdapter, Future>> { + @override + Future> adapt(Future Function() call) async { + try { + final response = await call(); + return Result.ok(response); + } catch (e) { + return Result.err(e.toString()); + } + } + } + + @RestApi(callAdapter: MyCallAdapter) + abstract class RestClient { + factory RestClient(Dio dio, {String? baseUrl}) = _RestClient; + + @GET('/') + Future> getUser(); + } +``` + ### Multiple endpoints support If you want to use multiple endpoints to your `RestClient`, you should pass your base url when you initiate `RestClient`. Any value defined in `RestApi` will be ignored. diff --git a/generator/CHANGELOG.md b/generator/CHANGELOG.md index cb49f758..4285d28f 100644 --- a/generator/CHANGELOG.md +++ b/generator/CHANGELOG.md @@ -1,3 +1,35 @@ + +## 9.1.7 + +- Introduced CallAdapters, This feature allows adaptation of a Call with return type R into the type of T. + e.g. Future to Future> + + Code Example: + +```dart + class MyCallAdapter extends CallAdapter, Future>> { + @override + Future> adapt(Future Function() call) async { + try { + final response = await call(); + return Either.right(response); + } + catch (e) { + return Either.left(ApiError(e)) + } + } + } + + @RestApi() + abstract class RestClient { + factory RestClient(Dio dio, {String? baseUrl}) = _RestClient; + + @UseCallAdapter(MyCallAdapter) + @GET('/') + Future getTasks(); + } +``` + ## 9.1.6 - Update `analyzer`, `dart_style` and `source_gen` dependencies to allow upper versions diff --git a/generator/lib/src/generator.dart b/generator/lib/src/generator.dart index 495f1e08..a6620e38 100644 --- a/generator/lib/src/generator.dart +++ b/generator/lib/src/generator.dart @@ -1,6 +1,5 @@ import 'dart:ffi'; import 'dart:io'; - import 'package:analyzer/dart/constant/value.dart'; import 'package:analyzer/dart/element/element.dart'; import 'package:analyzer/dart/element/nullability_suffix.dart'; @@ -72,6 +71,8 @@ class RetrofitGenerator extends GeneratorForAnnotation { /// Annotation details for [retrofit.RestApi] late retrofit.RestApi clientAnnotation; + ConstantReader? clientAnnotationConstantReader; + @override String generateForAnnotatedElement( Element element, @@ -97,6 +98,7 @@ class RetrofitGenerator extends GeneratorForAnnotation { baseUrl: annotation?.peek(_baseUrlVar)?.stringValue ?? '', parser: parser ?? retrofit.Parser.JsonSerializable, ); + clientAnnotationConstantReader = annotation; final baseUrl = clientAnnotation.baseUrl; final annotateClassConsts = element.constructors .where((c) => !c.isFactory && !c.isDefaultConstructor); @@ -222,16 +224,169 @@ class RetrofitGenerator extends GeneratorForAnnotation { c.body = Block.of(block); } }); + + // Traverses a type to find a matching type argument + // e.g. given a type `List>` and a key `User`, it will return the `DartType` "User" + DartType? findMatchingTypeArgument(DartType? type, String key) { + if (type?.getDisplayString() == key) { + return type; + } + + if (type is InterfaceType) { + for (final arg in type.typeArguments) { + final match = findMatchingTypeArgument(arg, key); + if (match != null) { + return match; + } + } + } + return null; + } + + // retrieve CallAdapter from method annotation or class annotation + ConstantReader? getCallAdapterInterface(MethodElement m) { + final requestCallAdapterAnnotation = _typeChecker(retrofit.UseCallAdapter) + .firstAnnotationOf(m) + .toConstantReader(); + final rootCallAdapter = clientAnnotationConstantReader; + + final callAdapter = (requestCallAdapterAnnotation ?? rootCallAdapter) + ?.peek('callAdapter'); + + final callAdapterTypeValue = callAdapter?.typeValue as InterfaceType?; + if (callAdapterTypeValue != null) { + final typeArg = callAdapterTypeValue.typeArguments.firstOrNull; + if (typeArg == null) { + throw InvalidGenerationSource( + 'your CallAdapter subclass must accept a generic type parameter \n' + 'e.g. "class ResultAdapter extends CallAdapter..."', + ); + } + } + return callAdapter; + } + + /// get result type being adapted to e.g. Future> + /// where T is supposed to be the wrapped result type + InterfaceType? getAdaptedReturnType(ConstantReader? callAdapter) { + final callAdapterTypeVal = callAdapter?.typeValue as InterfaceType?; + final adaptedType = + callAdapterTypeVal?.superclass?.typeArguments.lastOrNull as InterfaceType?; + return adaptedType; + } + + /// extract the wrapped result type of an adapted call... + /// Usage scenario: + /// given the return type of the api method is `Future>`, + /// and the second type parameter(T) on CallAdapter is `Future>`, + /// this method basically figures out the value of 'T' which will be "UserResponse" + /// in this case + String extractWrappedResultType(String template, String actual) { + final regexPattern = RegExp( + RegExp.escape(template).replaceAll('dynamic', r'([\w<>]+)'), + ); + final match = regexPattern.firstMatch(actual); + + if (match != null && match.groupCount > 0) { + return match.group(1) ?? ''; + } + return ''; + } - Iterable _parseMethods(ClassElement element) => [ - ...element.methods, - ...element.mixins.expand((i) => i.methods), - ].where((m) { - final methodAnnotation = _getMethodAnnotation(m); - return methodAnnotation != null && - m.isAbstract && - (m.returnType.isDartAsyncFuture || m.returnType.isDartAsyncStream); - }).map((m) => _generateMethod(m)!); + // parse methods in the Api class + Iterable _parseMethods(ClassElement element) { + List methods = []; + final methodMembers = [ + ...element.methods, + ...element.mixins.expand((i) => i.methods), + ]; + for (final method in methodMembers) { + final callAdapter = getCallAdapterInterface(method); + final adaptedReturnType = getAdaptedReturnType(callAdapter); + final resultTypeInString = extractWrappedResultType( + adaptedReturnType != null ? _displayString(adaptedReturnType) : '', + _displayString(method.returnType), + ); + final typeArg = findMatchingTypeArgument(method.returnType, resultTypeInString); + final instantiatedCallAdapter = typeArg != null ? + (callAdapter?.typeValue as InterfaceType?)?.element.instantiate( + typeArguments: [typeArg], + nullabilitySuffix: NullabilitySuffix.none, + ) : null; + if (method.isAbstract) { + methods.add(_generateApiCallMethod(method, instantiatedCallAdapter)!); + } + if (callAdapter != null) { + methods.add(_generateAdapterMethod(method, instantiatedCallAdapter, resultTypeInString)); + } + } + return methods; + } + + Method _generateAdapterMethod( + MethodElement m, + InterfaceType? callAdapter, + String resultType, + ) { + return Method((methodBuilder) { + methodBuilder.returns = + refer(_displayString(m.returnType, withNullability: true)); + methodBuilder.requiredParameters.addAll( + _generateParameters( + m, + (it) => it.isRequiredPositional, + ), + ); + methodBuilder.optionalParameters.addAll( + _generateParameters( + m, + (it) => it.isOptional || it.isRequiredNamed, + optional: true, + ), + ); + methodBuilder.name = m.displayName; + methodBuilder.annotations.add(const CodeExpression(Code('override'))); + final positionalArgs = []; + final namedArgs = []; + for (final parameter in m.parameters) { + if (parameter.isRequiredPositional || parameter.isOptionalPositional) { + positionalArgs.add(parameter.displayName); + } + if (parameter.isNamed) { + namedArgs.add('${parameter.displayName}: ${parameter.displayName}'); + } + } + final args = + '${positionalArgs.map((e) => '$e,').join()} ${namedArgs.map((e) => '$e,').join()}'; + methodBuilder.body = Code(''' + return ${callAdapter?.element.name}<$resultType>().adapt( + () => _${m.displayName}($args), + ); + '''); + }); + } + + Iterable _generateParameters( + MethodElement m, + bool Function(ParameterElement) filter, { + bool optional = false, + }) { + return m.parameters.where(filter).map( + (it) => Parameter( + (p) => p + ..name = it.name + ..named = it.isNamed + ..type = refer(it.type.getDisplayString()) + ..required = optional && + it.isNamed && + it.type.nullabilitySuffix == NullabilitySuffix.none && + !it.hasDefaultValue + ..defaultTo = optional && it.defaultValueCode != null + ? Code(it.defaultValueCode!) + : null, + ), + ); + } String _generateTypeParameterizedName(TypeParameterizedElement element) => element.displayName + @@ -369,61 +524,82 @@ class RetrofitGenerator extends GeneratorForAnnotation { return _getResponseInnerType(generic); } - Method? _generateMethod(MethodElement m) { - final httpMethod = _getMethodAnnotation(m); - if (httpMethod == null) { - return null; - } + void _configureMethodMetadata( + MethodBuilder mm, + MethodElement m, + String returnType, + bool hasCallAdapter, + ) { + mm + ..returns = refer(returnType) + ..name = hasCallAdapter ? '_${m.displayName}' : m.displayName + ..types.addAll(m.typeParameters.map((e) => refer(e.name))) + ..modifier = _isReturnTypeFuture(returnType) + ? MethodModifier.async + : MethodModifier.asyncStar; + } - return Method((mm) { - mm - ..returns = - refer(_displayString(m.type.returnType, withNullability: true)) - ..name = m.displayName - ..types.addAll(m.typeParameters.map((e) => refer(e.name))) - ..modifier = m.returnType.isDartAsyncFuture - ? MethodModifier.async - : MethodModifier.asyncStar - ..annotations.add(const CodeExpression(Code('override'))); - - if (globalOptions.useResult ?? false) { - final returnType = m.returnType; - if (returnType is ParameterizedType && - returnType.typeArguments.first is! VoidType) { - mm.annotations.add(const CodeExpression(Code('useResult'))); - } + void _addParameters(MethodBuilder mm, MethodElement m) { + mm.requiredParameters.addAll( + _generateParameters(m, (it) => it.isRequiredPositional), + ); + mm.optionalParameters.addAll( + _generateParameters(m, (it) => it.isOptional || it.isRequiredNamed, + optional: true), + ); + } + + void _addAnnotations( + MethodBuilder mm, + DartType? returnType, + bool hasCallAdapter, + ) { + if (!hasCallAdapter) { + mm.annotations.add(const CodeExpression(Code('override'))); + } + if (globalOptions.useResult ?? false) { + if (returnType is ParameterizedType && + returnType.typeArguments.first is! VoidType) { + mm.annotations.add(const CodeExpression(Code('useResult'))); } + } + } - /// required parameters - mm.requiredParameters.addAll( - m.parameters.where((it) => it.isRequiredPositional).map( - (it) => Parameter( - (p) => p - ..name = it.name - ..named = it.isNamed - ..type = refer(it.type.getDisplayString()), - ), - ), - ); + // generate the method that makes the http request + Method? _generateApiCallMethod(MethodElement m, InterfaceType? callAdapter) { + final hasCallAdapter = callAdapter != null; - /// optional positional or named parameters - mm.optionalParameters.addAll( - m.parameters.where((i) => i.isOptional || i.isRequiredNamed).map( - (it) => Parameter( - (p) => p - ..required = (it.isNamed && - it.type.nullabilitySuffix == NullabilitySuffix.none && - !it.hasDefaultValue) - ..name = it.name - ..named = it.isNamed - ..type = refer(it.type.getDisplayString()) - ..defaultTo = it.defaultValueCode == null - ? null - : Code(it.defaultValueCode!), - ), - ), - ); - mm.body = _generateRequest(m, httpMethod); + if (hasCallAdapter) { + return _generatePrivateApiCallMethod(m, callAdapter); + } + + final httpMethod = _getMethodAnnotation(m); + if (httpMethod == null) return null; + + final returnType = m.returnType; + return Method((methodBuilder) { + _configureMethodMetadata(methodBuilder, m, + _displayString(returnType, withNullability: true), false); + _addParameters(methodBuilder, m); + _addAnnotations(methodBuilder, returnType, false); + methodBuilder.body = + _generateRequest(m, httpMethod, null); + }); + } + + Method? _generatePrivateApiCallMethod( + MethodElement m, InterfaceType? callAdapter) { + final callAdapterOriginalReturnType = callAdapter?.superclass + ?.typeArguments.firstOrNull as InterfaceType?; + + final httpMethod = _getMethodAnnotation(m); + if (httpMethod == null) return null; + + return Method((methodBuilder) { + _configureMethodMetadata(methodBuilder, m, _displayString(callAdapterOriginalReturnType), true); + _addParameters(methodBuilder, m); + _addAnnotations(methodBuilder, m.returnType, true); + methodBuilder.body = _generateRequest(m, httpMethod, callAdapter); }); } @@ -440,7 +616,13 @@ class RetrofitGenerator extends GeneratorForAnnotation { return literal(definePath); } - Code _generateRequest(MethodElement m, ConstantReader httpMethod) { + bool _isReturnTypeFuture(String type) => type.startsWith('Future<'); + + Code _generateRequest( + MethodElement m, + ConstantReader httpMethod, + InterfaceType? callAdapter, + ) { final returnAsyncWrapper = m.returnType.isDartAsyncFuture ? 'return' : 'yield'; final path = _generatePath(m, httpMethod); @@ -549,8 +731,6 @@ class RetrofitGenerator extends GeneratorForAnnotation { refer(receiveProgress.element.displayName); } - final wrappedReturnType = _getResponseType(m.returnType); - blocks.add( declareFinal(_optionsVar) .assign(_parseOptions(m, namedArguments, blocks, extraOptions)) @@ -559,20 +739,20 @@ class RetrofitGenerator extends GeneratorForAnnotation { final options = refer(_optionsVar).expression; - if (wrappedReturnType == null || 'void' == wrappedReturnType.toString()) { - blocks.add( - refer('await $_dioVar.fetch') - .call([options], {}, [refer('void')]).statement, - ); - return Block.of(blocks); - } + final wrappedReturnType = _getResponseType( + callAdapter != null + ? callAdapter.superclass!.typeArguments.first + : m.returnType, + ); + final isWrappedWithHttpResponseWrapper = wrappedReturnType != null + ? _typeChecker(retrofit.HttpResponse).isExactlyType(wrappedReturnType) + : false; - final isWrapped = - _typeChecker(retrofit.HttpResponse).isExactlyType(wrappedReturnType); - final returnType = - isWrapped ? _getResponseType(wrappedReturnType) : wrappedReturnType; + final returnType = isWrappedWithHttpResponseWrapper + ? _getResponseType(wrappedReturnType) + : wrappedReturnType; if (returnType == null || 'void' == returnType.toString()) { - if (isWrapped) { + if (isWrappedWithHttpResponseWrapper) { blocks ..add( refer('final $_resultVar = await $_dioVar.fetch') @@ -1004,7 +1184,7 @@ You should create a new class to encapsulate the response. ); } } - if (isWrapped) { + if (isWrappedWithHttpResponseWrapper) { blocks.add( Code(''' final httpResponse = HttpResponse($_valueVar, $_resultVar); @@ -2661,6 +2841,11 @@ extension DartObjectX on DartObject? { bool get isEnum { return this?.type?.element?.kind.name == 'ENUM'; } + + ConstantReader? toConstantReader() { + if (this == null) return null; + return ConstantReader(this); + } } extension ReferenceExt on Reference { diff --git a/generator/pubspec.yaml b/generator/pubspec.yaml index e1d0d771..070b3444 100644 --- a/generator/pubspec.yaml +++ b/generator/pubspec.yaml @@ -20,10 +20,13 @@ dependencies: dart_style: '>=2.3.7 <4.0.0' dio: ^5.0.0 protobuf: ^3.1.0 - retrofit: ^4.4.1 source_gen: '>=1.5.0 <3.0.0' + retrofit: ^4.4.2 dev_dependencies: lints: any source_gen_test: ^1.0.6 test: ^1.25.0 +# dependency_overrides: +# retrofit: +# path: ../retrofit diff --git a/generator/test/src/generator_test_src.dart b/generator/test/src/generator_test_src.dart index 5973af6d..054bfbd8 100644 --- a/generator/test/src/generator_test_src.dart +++ b/generator/test/src/generator_test_src.dart @@ -1,12 +1,84 @@ import 'dart:io'; - import 'package:dio/dio.dart' hide Headers; import 'package:retrofit/retrofit.dart'; import 'package:source_gen_test/annotations.dart'; - import 'query.pb.dart'; -enum FileType { mp4, mp3 } +class Resource {} + +class MockCallAdapter1 extends CallAdapter, Future>> { + @override + Future> adapt(Future Function() call) async { + return Resource(); + } +} + +@ShouldGenerate( + ''' + @override + Future>> getUser() { + return MockCallAdapter1>().adapt(() => _getUser()); + } +''', + contains: true, +) +@RestApi() +abstract class TestCallAdapter1 { + @UseCallAdapter(MockCallAdapter1) + @GET('path') + Future>> getUser(); +} + +class Either {} + +class MockCallAdapter2 + extends CallAdapter, Future>> { + @override + Future> adapt(Future Function() call) async { + return Either(); + } +} + +@ShouldGenerate( + ''' + @override + Future> getUser() { + return MockCallAdapter2().adapt(() => _getUser()); + } +''', + contains: true, +) +@RestApi() +abstract class TestCallAdapter2 { + @UseCallAdapter(MockCallAdapter2) + @GET('path') + Future> getUser(); +} + +class Flow {} + +class MockCallAdapter3 extends CallAdapter, Flow> { + @override + Flow adapt(Future Function() call) { + return Flow(); + } +} + +@ShouldGenerate( + ''' + @override + Flow getUser() { + return MockCallAdapter3().adapt(() => _getUser()); + } +''', + contains: true, +) +@RestApi() +abstract class TestCallAdapter3 { + @UseCallAdapter(MockCallAdapter3) + @GET('path') + Flow getUser(); +} class Config { const Config({ @@ -22,6 +94,8 @@ class Config { final Map subConfig; } +enum FileType { mp4, mp3 } + class DummyTypedExtras extends TypedExtras { const DummyTypedExtras({ required this.id, diff --git a/retrofit/CHANGELOG.md b/retrofit/CHANGELOG.md index 5c70f2ef..1d832e2a 100644 --- a/retrofit/CHANGELOG.md +++ b/retrofit/CHANGELOG.md @@ -1,5 +1,35 @@ # Changelog +## 4.4.2 + +- Introduced CallAdapters, This feature allows adaptation of a Call with return type R into the type of T. + e.g. Future to Future> + + Code Example: +```dart + class MyCallAdapter extends CallAdapter, Future>> { + @override + Future> adapt(Future Function() call) async { + try { + final response = await call(); + return Either.right(response); + } + catch (e) { + return Either.left(ApiError(e)) + } + } + } + + @RestApi() + abstract class RestClient { + factory RestClient(Dio dio, {String? baseUrl}) = _RestClient; + + @UseCallAdapter(MyCallAdapter) + @GET('/') + Future getTasks(); + } +``` + ## 4.4.0 - Added `@TypedExtras` to pass extra options to dio requests using custom annotations. diff --git a/retrofit/lib/call_adapter.dart b/retrofit/lib/call_adapter.dart new file mode 100644 index 00000000..cdbf596a --- /dev/null +++ b/retrofit/lib/call_adapter.dart @@ -0,0 +1,53 @@ +/// Adapts a Call with return type R into the type of T. +/// e.g. Future to Future> +abstract class CallAdapter { + T adapt(R Function() call); +} + + +/// By annotating a method with `@UseCallAdapter`, you can specify a custom adapter +/// class where you can adapt a call to another response wrapper +/// +/// ### Usage +/// +/// 1. Create the call adapter by extending [CallAdapter]: +/// pass in type parameters for the original call return type and adapted call return type. +/// Note: your adapter subclass must accept a single type parameter(T), where T is +/// the type of the unwrapped response from the original call. e.g. +/// "UserResponse" in "Future" +/// +/// ```dart +/// class ResultCallAdapter extends CallAdapter, Future>> { +/// @override +/// Future> adapt(Future Function() call) async { +/// try { +/// final response = await call(); +/// return Success(response); +/// } catch (e) { +/// return Error(e); +/// } +/// } +/// } + +/// ``` +/// +/// 2. Set the adapter on an API method or the entire API interface: +/// +/// - To apply the adapter to an individual method, use `@UseCallAdapter` on the method: +/// ```dart +/// @UseCallAdapter(ResultCallAdapter) +/// Future> fetchData(); +/// ``` +/// +/// - To apply it to all methods in an Api interface, pass the adapter to `@RestApi`: +/// ```dart +/// @RestApi(callAdapter: ResultCallAdapter) +/// abstract class MyApiService { +/// @GET('/data') +/// Future> fetchData(); +/// } +/// ``` +class UseCallAdapter { + const UseCallAdapter(this.callAdapter); + final Type callAdapter; +} diff --git a/retrofit/lib/http.dart b/retrofit/lib/http.dart index 91943ca9..59d0552a 100644 --- a/retrofit/lib/http.dart +++ b/retrofit/lib/http.dart @@ -67,6 +67,7 @@ class RestApi { const RestApi({ this.baseUrl, this.parser = Parser.JsonSerializable, + this.callAdapter, }); /// Set the API base URL. @@ -93,6 +94,7 @@ class RestApi { /// if you don't specify the [parser]. It will be [Parser.JsonSerializable] final Parser parser; + final Type? callAdapter; } @immutable diff --git a/retrofit/lib/retrofit.dart b/retrofit/lib/retrofit.dart index ca751735..3647e441 100644 --- a/retrofit/lib/retrofit.dart +++ b/retrofit/lib/retrofit.dart @@ -1,3 +1,4 @@ export 'dio.dart'; export 'error_logger.dart'; export 'http.dart'; +export 'call_adapter.dart'; diff --git a/retrofit/pubspec.yaml b/retrofit/pubspec.yaml index 1b7103a1..a3d3f2c2 100644 --- a/retrofit/pubspec.yaml +++ b/retrofit/pubspec.yaml @@ -8,7 +8,7 @@ topics: - rest - dio - retrofit -version: 4.4.0 +version: 4.4.2 environment: sdk: '>=2.19.0 <4.0.0'