Another article about macros. Part 2

We continue from the previous article – so without further ado, let's move on to the examples.

Auto-dispose

For what?

It is more convenient to “hang” an annotation on a field that needs to be “turned off” when an object is deleted than to do it manually and go down to the method dispose.

What should it look like?

Let's define the entities to which we want to apply the macro – these are entities that have:

  • method dispose;

  • method close (For example, StreamController);

  • method cancel (For example, StreamSubscription).

  • alternative “switch off” method.

What happens if we apply a macro to a field that doesn't have a method dispose/close/cancel? Nothing critical, the analyzer will simply tell us that, surprise, the field does not have a dispose method.

@AutoDispose()
class SomeModel {
  @disposable
  final ValueNotifier<int> a;
  @closable
  final StreamController<int> b;
  @cancelable
  final StreamSubscription<int> c;
  @Disposable('customDispose')
  final CustomDep d;

  SomeModel({required this.a, required this.b, required this.c, required this.d});
}

class CustomDep {
  void customDispose() {}
}

augment library 'package:test_macros/3.%20auto_dispose/example.dart';

augment class SomeModel {
	void dispose() {
		a.dispose();
		b.close();
		c.cancel();
		d.customDispose();
	}
}

How to implement this?

To start with, the simplest thing is to create annotations disposable, cancelable, closable And Disposable:

const disposeMethod = 'dispose';
const closeMethod = 'close';
const cancelMethod = 'cancel';

const disposableAnnotationName="disposable";
const closableAnnotationName="closable";
const cancelableAnnotationName="cancelable";
const customDisposableAnnotationName="Disposable";
const customDisposableFieldName="disposeMethodName";


const disposable = Disposable(disposeMethod);
const closable = Disposable(closeMethod);
const cancelable = Disposable(cancelMethod);

class Disposable {
  final String disposeMethodName;
  const Disposable(this.disposeMethodName);
}

It's time to create a macro. As in previous cases, let's select the macro phase:

  • the types phase is not suitable for us – we are not going to create new types;

  • the declaration phase allows us to add code inside the class – that's what we want;

  • It's as if we don't need the definition phase, because we can do everything necessary in the declaration phase.

import 'dart:async';

import 'package:macros/macros.dart';

macro class AutoDispose implements ClassDeclarationsMacro {
  const AutoDispose();

  @override
  FutureOr<void> buildDeclarationsForClass(ClassDeclaration clazz, MemberDeclarationBuilder builder) async {
    final fields = await builder.fieldsOf(clazz);
  }
}

Let's create a dictionary where the key will be the field name and the value will be the name of the method that needs to be called:

final fields = await builder.fieldsOf(clazz);

    /// Ключ - имя поля, значение - имя метода для вызова.
    final disposables = <String, Object>{};

    for (final field in fields) {
      Object? methodName;

      final annotations = field.metadata;

      /// Ищем аннотацию Disposable с кастомным именем метода.
      final customDispose = annotations.whereType<ConstructorMetadataAnnotation>().firstWhereOrNull(
            (element) => element.type.identifier.name == customDisposableAnnotationName,
          );

      if (customDispose != null) {
        methodName = customDispose.namedArguments[customDisposableFieldName];
      } else {
        /// Если аннотация не найдена, ищем стандартные аннотации.
        /// 
        /// - отсеиваем константные аннотации;
        /// - ищем аннотации, которые содержат нужные нам идентификаторы.
        /// - сопоставляем идентификаторы с методами.
        methodName = switch ((annotations.whereType<IdentifierMetadataAnnotation>().firstWhereOrNull(
              (element) => [
                disposableAnnotationName,
                closableAnnotationName,
                cancelableAnnotationName,
              ].contains(element.identifier.name),
            ))?.identifier.name) {
          disposableAnnotationName => disposeMethod,
          closableAnnotationName => closeMethod,
          cancelableAnnotationName => cancelMethod,
          _ => null,
        };
      }

      if (methodName != null) {
        disposables[field.identifier.name] = methodName;
      }
    }

The only thing left to do is to compile the method code dispose and add it to the class:

final code = <Object>[
      '\tvoid dispose() {\n',
      ...disposables.entries.map((e) {
        return ['\t\t${e.key}.', e.value, '();\n'];
      }).expand((e) => e),
      '\t}\n',
    ];

    builder.declareInType(DeclarationCode.fromParts(code));

It would seem like a victory! But then we look at the generated code and see this picture:

augment library 'package:test_macros/3.%20auto_dispose/example.dart';

augment class SomeModel {
	void dispose() {
		a.dispose();
		b.close();
		c.cancel();
		d.'customDispose'();
	}
}

Another knife in the back from ExpressionCode. We can only get the expression code, but not its value. And since the expression code contains a string value (with quotes), we cannot use it as a method name.

We are looking for workarounds. We could allow users to implement their own annotations. But then the macro would need to know the names of the new annotations so that it would take them into account when generating the method. dispose. In addition, he must know the names of the methods that need to be called.

So, the only option we came up with is to pass a dictionary to the macro, where the key is the name of the annotation, and the value is the name of the method:

@AutoDispose(
  disposeMethodNames: {
    'customDepDispose': 'customDispose',
  },
)
class SomeModel {
  @disposable
  final ValueNotifier<int> a;
  @closable
  final StreamController<int> b;
  @cancelable
  final StreamSubscription<int> c;
  @customDepDispose
  final CustomDep d;

  SomeModel({required this.a, required this.b, required this.c, required this.d});
}

const customDepDispose = Disposable('customDispose');

class CustomDep {
  void customDispose() {}
}

It looks terrible, yes. But nothing better came to our minds.

Let's make some changes to the macro – first, let's collect a dictionary of all possible annotations and methods:

macro class AutoDispose implements ClassDeclarationsMacro {
  final Map<String, String> disposeMethodNames;
  const AutoDispose({
    this.disposeMethodNames = const {},
  });

  @override
  FutureOr<void> buildDeclarationsForClass(ClassDeclaration clazz, MemberDeclarationBuilder builder) async {
    final allMethodNames = {
      disposableAnnotationName: disposeMethod,
      closableAnnotationName: closeMethod,
      cancelableAnnotationName: cancelMethod,
      ...disposeMethodNames,
    };
    ...
  }
}

We no longer need the search for a custom method, as well as the switch – now it will be a search by key in the dictionary:

for (final field in fields) {
      Object? methodName;

      final annotations = field.metadata;

      final annotationName = ((annotations.whereType<IdentifierMetadataAnnotation>().firstWhereOrNull(
            (element) => allMethodNames.keys.contains(element.identifier.name),
          ))?.identifier.name);

      methodName = allMethodNames[annotationName];

      if (methodName != null) {
        disposables[field.identifier.name] = methodName;
      }
    }

The rest of the code remains unchanged. We check the generated code and finally see the desired result:

augment library 'package:test_macros/3.%20auto_dispose/example.dart';

augment class SomeModel {
	void dispose() {
		a.dispose();
		b.close();
		c.cancel();
		d.customDispose();
	}
}

We try to launch the project and once again get a knife in the back:

Error: This macro application didn't apply correctly due to an unhandled map entry.

Despite the fact that our input parameter answers specification requirements (this is a dictionary with primitive data types), macro can't process it. We can pass parameters as a string (for example, 'customDepDispose: customDispose'), but this is inconvenient and unreadable.

Besides this, our example has another problem – it does not support calling the base method (not augment) class. By official examples you can call the method augmented() inside augment-method, but in practice we get an error – as if such a method does not exist.

Result

We got a macro that will work with preset entities. However, to work with custom ones, additional settings are needed, which, due to the current limitations of macros, can only be organized through hacks. But we regained faith in the benefits of annotations after their failure in the first experiment.

DI container

For what?

A typical DI container in a Flutter application looks like this:

class AppScope implements IAppScope {
  late final SomeDep _someDep;
  late final AnotherDep _anotherDep;
  late final ThirdDep _thirdDep;

  ISomeDep get someDep => _someDep;
  IAnotherDep get anotherDep => _anotherDep;
  IThirdDep get thirdDep => _thirdDep;

  AppScope(String someId) {
    _someDep = SomeDep(someId);
  }

  Future<void> init() async {
    _anotherDep = await AnotherDep.create();
    _thirdDep = ThirdDep(_someDep);
  }
}

abstract interface class IAppScope {
  ISomeDep get someDep;
  IAnotherDep get anotherDep;
  IThirdDep get thirdDep;
}

It would be great to have a container instead that:

  • allows you to specify dependencies directly in the initializer;

  • supports asynchronous initialization;

  • protected from cyclic dependencies;

  • looks laconic.

What should it look like?

Something like this:

@DiContainer()
class AppScope {
  late final Registry<SomeDependency> _someDependency = Registry(() {
    return SomeDependency();
  });
  late final Registry<AnotherDependency> _anotherDependency = Registry(() {
    return AnotherDependency(someDependency);
  });
  late final Registry<ThirdDependency> _thirdDependency = Registry(() {
    return ThirdDependency(someDependency, anotherDependency);
  });
}

augment class AppScope {
  late final ISomeDependency someDependency;
  late final IAnotherDependency anotherDependency;
  late final IThirdDependency thirdDependency;

  Future<void> init() async {
    someDependency = await _someDependency();
    anotherDependency = await _anotherDependency();
    thirdDependency = await _thirdDependency();
  }
}

How to implement this?

Let's break the task down into subtasks:

  • select a phase and create an annotation;

  • creating a class Registry;

  • creating a method init;

  • construction of the initialization order;

  • Creation late final fields.

Selecting a phase and creating an annotation

Let's select the declaration phase – we need to add code inside the class. Let's create an annotation DiContainer:

macro class DiContainer implements ClassDeclarationsMacro {
  const DiContainer();
  @override
  FutureOr<void> buildDeclarationsForClass(
    ClassDeclaration clazz,
    MemberDeclarationBuilder builder,
  ) async {}
}

Creating a Registry class

Let's create a class Registry:

class Registry<T> {
  final FutureOr<T> Function() create;

  Registry(this.create);

  FutureOr<T> call() => create();
}

Creating an init method

Everything is simple here – we use the good old builder.declareInType:

@override
  FutureOr<void> buildDeclarationsForClass(
    ClassDeclaration clazz,
    MemberDeclarationBuilder builder,
  ) async {
    final initMethodParts = <Object>[
      'Future<void> init() async {\n',
    ];

    initMethodParts.add('}');

    builder.declareInType(DeclarationCode.fromParts(initMethodParts));
  }

Building the initialization order

And here comes the most interesting and difficult part. We are trying to determine the order of initialization of fields. To do this, we need:

  • collect a list of dependencies for each field;

  • define the initialization order so that dependencies are initialized before the fields that depend on them.

First of all, we will assemble a dictionary: the key will be the name of the field with the dependency, and the value will be a list of parameters that are required to initialize it. Conventionally, for our example, the dictionary will be like this:

{
  someDependency: [],
  anotherDependency: [someDependency],
  thirdDependency: [someDependency, anotherDependency],
}

Let's do this:

final dependencyToConstructorParams = <String, List<String>>{};

    for (final field in fields) {
      final type = field.type;
      if (type is! NamedTypeAnnotation) continue;
      /// Отсекаем все поля, которые не являются Registry.
      if (type.identifier.name != 'Registry') continue;

      final generic = type.typeArguments.firstOrNull;

      if (generic is! NamedTypeAnnotation) continue;

      final typeDeclaration = await builder.typeDeclarationOf(generic.identifier);

      if (typeDeclaration is! ClassDeclaration) continue;

      final fields = await builder.fieldsOf(typeDeclaration);

      final constructorParams = fields.where((e) => !e.hasInitializer).toList();

      dependencyToConstructorParams[field.identifier.name.replaceFirst('_', '')] = constructorParams.map((e) => e.identifier.name.replaceFirst('_', '')).toList();
    }

Now let's define the initialization order. For this, we'll use topological sorting. We already have a graph, all that's left is to implement the algorithm itself:

List<T> _topologicalSort<T>(
    Map<T, List<T>> graph,
    MemberDeclarationBuilder builder,
  ) {
    /// Обработанные вершины.
    final visited = <T>{};

    /// Вершины, в которых мы находимся на текущий момент.
    final current = <T>{};

    /// Вершины, записанные в топологическом порядке.
    final result = <T>[];

    /// Рекурсивная функция обхода графа.
    /// Возвращает [T], который образует цикл. Если цикла нет, возращает null.
    T? process(T node) {
      /// Если вершина уже обрабатывается, значит, мы нашли цикл.
      if (current.contains(node)) {
        return node;
      }

      /// Если вершина уже обработана, то пропускаем её.
      if (visited.contains(node)) {
        return null;
      }

      /// Добавляем вершину в текущие.
      current.add(node);

      /// Повторяем для всех соседей.
      for (final neighbor in graph[node] ?? []) {
        final result = process(neighbor);
        if (result != null) {
          return result;
        }
      }

      current.remove(node);
      visited.add(node);
      result.add(node);
      return null;
    }

    for (final node in graph.keys) {
      final cycleAffectingNode = process(node);

      /// Если обнаружен цикл, то выбрасываем исключение.
      if (cycleAffectingNode != null) {
        builder.report(
          Diagnostic(
            DiagnosticMessage(
             '''Cycle detected in the graph. '''
             '''$cycleAffectingNode requires ${graph[cycleAffectingNode]?.join(', ')}''',
            ),
            Severity.error,
          ),
        );
        throw Exception();
      }
    }
    return result;
  }

Now that we have the order of calls, we can complete the function. init:

@override
  FutureOr<void> buildDeclarationsForClass(
    ClassDeclaration clazz,
    MemberDeclarationBuilder builder,
  ) async {
    ...
    final sorted = _topologicalSort(
      dependencyToConstructorParams,
      builder,
    );

    for (final dep in sorted) {
      if (!dependencyToConstructorParams.keys.contains(dep)) continue;
        /// Получаем что-то вроде:
        /// ```
        /// someDependency = await _someDependency();
        /// ```
        initMethodParts.addAll([
          '\t\t$dep = await _$dep();\n',
        ]);
    }

    initMethodParts.add('}');

    builder.declareInType(DeclarationCode.fromParts(initMethodParts));
  }

Creating late final fields

Finally, we create late final fields. Unfortunately, Registry uses a generic of a specific type. Because of this, the interface of the class behind which we want to hide the implementation is not directly available to us. Therefore, we take the first of the available interfaces (if there is one):

 for (final field in fields) {
      ...
      dependencyToConstructorParams[field.identifier.name.replaceFirst('_', '')] =
          constructorParams.map((e) => e.identifier.name.replaceFirst('_', '')).toList();

++    final superClass = typeDeclaration.interfaces.firstOrNull;
++
++    builder.declareInType(
++      DeclarationCode.fromParts(
++        [
++          'late final ',
++          superClass?.code ?? generic.code,
++          ' ${field.identifier.name.replaceFirst('_', '')};',
++        ],
++      ),
++    );
++  }

Result

Apply the macro to the class AppScope:

@DiContainer()
class AppScope {
  late final Registry<SomeDependency> _someDependency = Registry(() {
    return SomeDependency();
  });
  late final Registry<AnotherDependency> _anotherDependency = Registry(() {
    return AnotherDependency(someDependency);
  });
  late final Registry<ThirdDependency> _thirdDependency = Registry(() {
    return ThirdDependency(someDependency, anotherDependency);
  });


  AppScope();
}

and we get:

augment library 'package:test_macros/5.%20di_container/example.dart';

import 'package:test_macros/5.%20di_container/example.dart' as prefix0;

import 'dart:core';
import 'dart:async';
augment class AppScope {
late final prefix0.ISomeDependency someDependency;
late final prefix0.IAnotherDependency anotherDependency;
late final prefix0.IThirdDependency thirdDependency;
Future<void> init() async {
		someDependency = await _someDependency();
		anotherDependency = await _anotherDependency();
		thirdDependency = await _thirdDependency();
	}
}

Let's try to add IAnotherDependency as a parameter for dependency SomeDependency:

@DiContainer()
class AppScope {
  late final Registry<SomeDependency> _someDependency = Registry(() {
    return SomeDependency(anotherDependency);
  });
  ...
}

And we get an error:

Result

There are many “thin” places in this implementation. For example, we are tied to the fact that the user must set initializers strictly private. And we cannot set the names of public fields (even using annotations, since the parameters passed to them will be available to us only as ExpressionCode).

We also cannot explicitly specify the interface under which we would like to see the public field. In theory, of course, we can add a second generic to Registrybut this will deprive us of brevity.

Despite all this, we have a working prototype of a DI container that can be further developed and improved.

Retrofit on macros

For what?

The classic Dart version of retrofit works with build_runner. Seems like a potential target to port to macros.

What should it look like?

@RestClient()
class Client {
  Client(
    this.dio, {
    this.baseUrl,
  });

  @GET('/posts/{id}')
  external Future<UserInfoDto> getUserInfo(int id);

  @GET('/convert')
  external Future<SomeEntity> convert(@Query() String from, @Query() String to);
}

augment class Client {
  final Dio dio;
  final String? baseUrl;

  augment Future<PostEntity> getUserInfo(int id) async {
		final queryParameters = <String, dynamic>{};
		final _result  = await dio.fetch<Map<String, dynamic>>(Options(
		  method: 'GET',
		)
		.compose(
			dio.options,
			"/posts/${id}",
			queryParameters: queryParameters,
		)
    .copyWith(baseUrl: baseUrl ?? dio.options.baseUrl));
		final value = PostEntity.fromJson(_result.data!);
		return value;
	}

  augment Future<PostEntity> convert(String from, String to) async {
		final queryParameters = <String, dynamic>{
			'from': from,
			'to': to,
		};
		final _result  = await dio.fetch<Map<String, dynamic>>(Options(
		  method: 'GET',
		)
		.compose(
			dio.options,
			"/convert",
			queryParameters: queryParameters,
		)
    .copyWith(baseUrl: baseUrl ?? dio.options.baseUrl));
		final value = PostEntity.fromJson(_result.data!);
		return value;
	}
}

For now we will limit ourselves to GET requests, query and path parameters.

How to implement this?

As usual, let's start by creating annotations:

const query = Query();

class Query {
  const Query();
}

There will be two macros here:

The most appropriate phase of the macro for the client is the declaration phase: we need to add two fields to the class.

Let's create a macro and write the name of the class fields into constants:

import 'dart:async';

import 'package:macros/macros.dart';

const baseUrlVarSignature="baseUrl";
const dioVarSignature="dio";

macro class RestClient implements ClassDeclarationsMacro {
  @override
  FutureOr<void> buildDeclarationsForClass(ClassDeclaration clazz, MemberDeclarationBuilder builder) async {
    /// Добавим импорт Dio.
    builder.declareInLibrary(DeclarationCode.fromString('import \'package:dio/dio.dart\';'));
  }
}

Let's get a list of fields, make sure the fields we're going to create aren't there, and if so, create them:

 final fields = await builder.fieldsOf(clazz);

    builder.declareInLibrary(DeclarationCode.fromString('import \'package:dio/dio.dart\';'));

    /// Проверяем, имеет ли класс поле baseUrl.
    final indexOfBaseUrl = fields.indexWhere((element) => element.identifier.name == baseUrlVarSignature);
    if (indexOfBaseUrl == -1) {
      final stringType = await builder.resolveIdentifier(Uri.parse('dart:core'), 'String');
      builder.declareInType(DeclarationCode.fromParts(['\tfinal ', stringType, '? $baseUrlVarSignature;']));
    } else {
      builder.report(
        Diagnostic(
          DiagnosticMessage('$baseUrlVarSignature is already defined.'),
          Severity.error,
        ),
      );
      return;
    }

    final indexOfDio = fields.indexWhere((element) => element.identifier.name == dioVarSignature);
    if (indexOfDio == -1) {
      builder.declareInType(DeclarationCode.fromString('\tfinal Dio $dioVarSignature;'));
    } else {
      builder.report(
        Diagnostic(
          DiagnosticMessage('$dioVarSignature is already defined.'),
          Severity.error,
        ),
      );
      return;
    }

Now let's get to the method. For the macro GET take the definition phase – we need to write the implementation of the already declared method. Also add the declaration phase to add imports. They will make our life easier and save us from having to import a bunch of types manually.

macro class GET implements MethodDeclarationsMacro, MethodDefinitionMacro {
  final String path;

  const GET(this.path);


  @override
  FutureOr<void> buildDeclarationsForMethod(MethodDeclaration method, MemberDeclarationBuilder builder) async {
    builder.declareInLibrary(DeclarationCode.fromString('import \'dart:core\';'));
  }

  @override
  FutureOr<void> buildDefinitionForMethod(MethodDeclaration method, FunctionDefinitionBuilder builder) async {
    
  }
}

We face several challenges:

  • define the return type of the value to implement parsing;

  • collect query parameters;

  • substitute parameters into path if any;

  • collect it all.

We need to define the return type. It is assumed that we will apply the method to it fromJsonto parse the server's response. It is worth considering cases when we try to get a collection (List) or we get no value (void). Let's create an enum for the types of return values:

/// Общий тип, который возвращает метод:
/// - коллекция
/// - одно значение
/// - ничего
enum ReturnType { collection, single, none }

Now you can define the return type (that is, get the generic from Future or Future<List>):

 @override
  FutureOr<void> buildDefinitionForMethod(MethodDeclaration method, FunctionDefinitionBuilder builder) async {
  /// Здесь у нас будет что-то вроде `Future<UserInfoDto>`.
    var type = method.returnType;

    /// Сюда запишем тип возвращаемого значения.
    NamedTypeAnnotation? valueType;
    late ReturnType returnType;

    /// На случай, если тип возвращаемого значения опущен при объявлении метода, попробуем его получить.
    if (type is OmittedTypeAnnotation) {
      type = await builder.inferType(type);
    }

    if (type is NamedTypeAnnotation) {
      /// Проверяем, что тип возвращаемого значения - Future.
      if (type.identifier.name != 'Future') {
        builder.report(
          Diagnostic(
            DiagnosticMessage('The return type of the method must be a Future.'),
            Severity.error,
          ),
        );
        return;
      }

      /// Получаем джинерик типа. У Future он всегда один.
      final argType = type.typeArguments.firstOrNull;

      valueType = argType is NamedTypeAnnotation ? argType : null;

      switch (valueType?.identifier.name) {
        case 'List':
          returnType = ReturnType.collection;
          valueType = valueType?.typeArguments.firstOrNull as NamedTypeAnnotation?;
        case 'void':
          returnType = ReturnType.none;
        default:
          returnType = ReturnType.single;
      }
    } else {
      builder.report(
        Diagnostic(
          DiagnosticMessage('Cannot determine the return type of the method.'),
          Severity.error,
        ),
      );
      return;
    }

    if (valueType == null) {
      builder.report(
        Diagnostic(
          DiagnosticMessage('Cannot determine the return type of the method.'),
          Severity.error,
        ),
      );
      return;
    }
  }

Now let's collect query parameters into a dictionary of the following type:

final _queryParameters = <String, dynamic>{  
  'from': from,
  'to': to,
};

To do this, we will collect all the fields (named and positional) and take those that have an annotation @query:

/// Сюда будем собирать код для создания query параметров.
    final queryParamsCreationCode = <Object>[];

    final fields = [
      ...method.positionalParameters,
      ...method.namedParameters,
    ];

    /// Собираем query параметры.
    final queryParams = fields.where((e) => e.metadata.any((e) => e is IdentifierMetadataAnnotation && e.identifier.name == 'query')).toList();

Let's add the name of the variable for query parameters to our constants:

 const baseUrlVarSignature="baseUrl";
    const dioVarSignature="dio";
++  const queryVarSignature="_queryParameters";

Now, if we have query parameters, let's add them to the dictionary:

queryParamsCreationCode.addAll([
      '\t\tfinal $queryVarSignature = <String, dynamic>{\n',
      ...queryParams.map((e) => "\t\t\t'${e.name}': ${e.name},\n"),
      '\t\t};\n',
    ]);

Let's work on the request path – we'll substitute path parameters into it.

For example, if we have a path /posts/{id}then we should get a string '/posts/$id'.

пример, если у нас есть путь /posts/{id}, то мы должны получить строку '/posts/$id'.
   final substitutedPath = path.replaceAllMapped(RegExp(r'{(\w+)}'), (match) {
      final paramName = match.group(1);
      final param = fields.firstWhere((element) => element.identifier.name == paramName, orElse: () => throw ArgumentError('Parameter \'$paramName\' not found'));
      return '\${${param.identifier.name}}';
    });

It's time to assemble the query. Don't forget that we can get not only a single value, but also a collection. Or nothing. This is important to consider when using the method fetch and when parsing the response:

 builder.augment(FunctionBodyCode.fromParts([
      'async {\n',
      ...queryParamsCreationCode,
      '\t\tfinal _result  = await $dioVarSignature.fetch<',
      switch (returnType) {
        ReturnType.none => 'void',
        ReturnType.single => 'Map<String, dynamic>',
        ReturnType.collection => 'List<dynamic>',  
      },'>(\n',
      '\t\t\tOptions(\n',
		  '\t\t\t\tmethod: "GET",\n',
		  '\t\t\t)\n',
		  '\t\t.compose(\n',
		  '\t\t\t	$dioVarSignature.options,\n',
		  '\t\t\t	"$substitutedPath",\n',
		  '\t\t\t	queryParameters: $queryVarSignature,\n',
		  '\t\t)\n',
      '\t\t.copyWith(baseUrl: $baseUrlVarSignature ?? $dioVarSignature.options.baseUrl));\n',
		  ...switch (returnType) {
        ReturnType.none => [''],
        ReturnType.single => ['\t\tfinal value=", valueType.code, ".fromJson(_result.data!);\n'],
        ReturnType.collection => [
          '\t\tfinal value = (_result.data as List).map((e) => ', valueType.code, '.fromJson(e)).toList();\n',
          ],
      },
      if (returnType != ReturnType.none) '\t\treturn value;\n',
      '\t}',
    ]));

To check the result, we will use JSONPlaceholder — free API for testing HTTP requests.

// ignore_for_file: avoid_print

@DisableDuplicateImportCheck()
library example;

import 'package:test_macros/5.%20retrofit/annotations.dart';
import 'package:test_macros/5.%20retrofit/client_macro.dart';
import 'package:dio/dio.dart';

@RestClient()
class Client {
  Client(this.dio, {this.baseUrl});

  @GET('/posts/{id}')
  external Future<PostEntity> getPostById(int id);

  @GET('/posts')
  external Future<List<PostEntity>> getPostsByUserId(@query int user_id);
}

class PostEntity {
  final int? userId;
  final int? id;
  final String? title;
  final String? body;

  PostEntity({
    required this.userId,
    required this.id,
    required this.title,
    required this.body,
  });

  factory PostEntity.fromJson(Map<String, dynamic> json) {
    return PostEntity(
      userId: json['userId'],
      id: json['id'],
      title: json['title'],
      body: json['body'],
    );
  }

  Map<String, dynamic> toJson() {
    return {
      'userId': userId,
      'id': id,
      'title': title,
      'body': body,
    };
  }
}

Future<void> main() async {
  final dio = Dio()..interceptors.add(LogInterceptor(logPrint: print));
  final client = Client(dio, baseUrl: 'https://jsonplaceholder.typicode.com');

  const idOfExistingPost = 1;

  final post = await client.getPostById(idOfExistingPost);
  
  final userId = post.userId;

  if (userId != null) {
    final posts = await client.getPostsByUserId(userId);
    print(posts);
  }
}

We launch it and see the following:

 Error: 'String' isn't a type.
  Error: 'int' isn't a type.
  Error: 'dynamic' isn't a type.
  ...

This is another one that was discovered while writing the article. macro problem. Due to the fact that one file contains identical imports – with and without a prefix – the project cannot run:

import "dart:core";
import "dart:core" as prefix01;

So we'll have to rewrite almost all of the macro code to use only prefixed types.

We will obtain these types using the method resolveIdentifierwhich takes a library uri and a type name, and is also marked as deprecated even before the release:

 @override
  FutureOr<void> buildDefinitionForMethod(MethodDeclaration method, FunctionDefinitionBuilder builder) async {
    const stringTypeName="String";
    const dynamicTypeName="dynamic";
    const mapTypeName="Map";
    const optionsTypeName="Options";
    const listTypeName="List";

    final stringType = await builder.resolveIdentifier(Uri.parse('dart:core'), stringTypeName);
    final dynamicType = await builder.resolveIdentifier(Uri.parse('dart:core'), dynamicTypeName);
    final mapType = await builder.resolveIdentifier(Uri.parse('dart:core'), mapTypeName);
    final optionsType = await builder.resolveIdentifier(Uri.parse('package:dio/src/options.dart'), optionsTypeName);
    final listType = await builder.resolveIdentifier(Uri.parse('dart:core'), listTypeName);

    /// Шорткат для `<String, dynamic>`.
    final stringDynamicMapType = ['<', stringType, ', ', dynamicType, '>'];
    ...
  }

Now we need to replace all occurrences String, dynamic, Map, Options And List to the “permitted” types we received:

 queryParamsCreationCode.addAll([
--    '\t\tfinal $queryVarSignature = <String, dynamic>{\n',
++    '\t\tfinal $queryVarSignature=", ...stringDynamicMapType, "{\n',
      ...queryParams.map((e) => "\t\t\t'${e.name}': ${e.name},\n"),
      '\t\t};\n',
    ]);

And in this spirit we continue in all other places (_dio.fetch<Map<String, dynamic>>, Options and so on).

Now let's admire the result.

Result

Apply the macro to the class Client:

@RestClient()
class Client {
  Client(this.dio, {this.baseUrl});

  @GET('/posts/{id}')
  external Future<UserInfoDto> getUserInfo(int id);

  @GET('/convert')
  external Future<SomeEntity> convert(@query String from, @query String to);
}

и получим следующий код:
augment library 'package:test_macros/5.%20retrofit/example.dart';

import 'dart:async' as prefix0;
import 'package:test_macros/5.%20retrofit/example.dart' as prefix1;
import 'dart:core' as prefix2;
import 'package:dio/src/options.dart' as prefix3;

import 'package:dio/dio.dart';
import 'dart:core';
augment class Client {
	final String? baseUrl;
	final Dio dio;
  augment prefix0.Future<prefix1.PostEntity> getPostById(prefix2.int id, ) async {
		final _queryParameters = <prefix2.String, prefix2.dynamic>{
		};
		final _result  = await dio.fetch<prefix2.Map<prefix2.String, prefix2.dynamic>>(
			prefix3.Options(
				method: "GET",
			)
		.compose(
				dio.options,
				"/posts/${id}",
				queryParameters: _queryParameters,
		)
		.copyWith(baseUrl: baseUrl ?? dio.options.baseUrl));
		final value = prefix1.PostEntity.fromJson(_result.data!);
		return value;
	}
  augment prefix0.Future<prefix2.List<prefix1.PostEntity>> getPostsByUserId(prefix2.int user_id, ) async {
		final _queryParameters = <prefix2.String, prefix2.dynamic>{
			'user_id': user_id,
		};
		final _result  = await dio.fetch<prefix2.List<prefix2.dynamic>>(
			prefix3.Options(
				method: "GET",
			)
		.compose(
				dio.options,
				"/posts",
				queryParameters: _queryParameters,
		)
		.copyWith(baseUrl: baseUrl ?? dio.options.baseUrl));
		final value = (_result.data as prefix2.List).map((e) => prefix1.PostEntity.fromJson(e)).toList();
		return value;
	}
}

In fact, this is the tip of the iceberg – you can get acquainted with the full version of the so-called macrofit at pub.dev. This package is under development, but with its help you can make GET, POST, PUT and DELETE requests and work with query-, path- and part-parameters, set headers and body of the request.

As for our little example, macros are ideal for tasks like generating network requests. And if you combine this with @JsonCodable And @DataClassthen we get a fully automated process for creating network requests. And all we have to do is write a class skeleton and add annotations.

Conclusions

Despite all their capabilities, macros do not allow code generation as freely as code_builderThey have limitations, some of which we have discussed in this article.

Even so, macros are a huge step forward for Dart. They will fundamentally change the way we write code and automate many routine tasks. However, they are fraught with danger: code heavily peppered with macros will be difficult to read. And the possibility of side effects whose cause will not be obvious will increase significantly. But if you use this tool wisely, its benefits will far outweigh the possible drawbacks.

At the same time, we have a strong feeling that the macros are still too raw and unstable for a release in early 2025. I want to believe that we are wrong.

All examples from this article you will find it in the repository.

More useful information about Flutter — in the Surf Flutter Team Telegram channel.

Cases, best practices, news and vacancies for the Flutter Surf team in one place. Join us!

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *