commit 2a66744761fce8a3a4aaa94eecf337b38047fe78 Author: Diego Cardenas Date: Fri Oct 8 21:39:40 2021 -0500 Initial release of the generator package. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3d64647 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# Files and directories created by pub +.dart_tool/ +.packages + +# Conventional directory for build outputs +build/ + +# Directory created by dartdoc +doc/api/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..47fe2a3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.1.0 + +- Initial release of the annotation package. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..69be3ce --- /dev/null +++ b/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2021, Diego Cardenas +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..a9f040b --- /dev/null +++ b/README.md @@ -0,0 +1,221 @@ +A generator to create config class from json files that support many environments. + +# Motivation + +If you use a json file to config your applications, perphaps you need to write a dart class that contain all variables you define with their data types. When you need to manage nested objects is more complicated. Even if you want to add or delete a value in your json, you need to modify your dart class. + +If you want to manage environments you need to writte manually the file path for every enviroment you need. + +[json_config_generator] wants to help you to have an easy process with the manage of config files and environments. + +# Install + +To use [json_config_generator], you will need [build_runner]/code-generator setup.\ +First, install [build_runner] and [json_config_generator] by adding them to your `pubspec.yaml` file: + +> pubspec.yaml + +```yaml +dependencies: + json_config_annotation: ^0.1.0 + +dev_dependencies: + build_runner: + json_config_generator: ^0.1.0 +``` + +- [build_runner], the tool to run code-generators +- [json_config_generator], the code generator +- [json_config_annotation], a package containing annotations for [json_config_generator] + +# How to use + +To use this generator need to create an empty config dart class that begins with `$` using the annotation `Configuration` defining the environments that you want to have. Each environment has `name` and `path`, if have more that one, the generator create an `Enum` to environments. Also you can specify the name of that `Enum`. By default it's name is `Environment`. Also, you need to add some imports. + +> config.dart + +```dart +import 'package:json_config_annotation/json_config_annotation.dart'; +import 'dart:convert'; +import 'package:flutter/services.dart'; + +part 'config.g.dart'; //{dart file name}.g.dart + +@Configuration( + environmentEnumName: 'Env', + environments:[ + Environment(name:'dev', path:'assets/config/dev.json'), + Environment(name:'prd', path:'assets/config/prd.json'), + ], +) +class $Config{} +``` + +suposing that have the next json file + +> dev.json + +```json +{ + "base_url": "https://example.com", + "custom_class": { + "value_1": "dfgdfgdfgwqrrqwrqwrqweqwe324523b252dghfdhd", + "value_2": "6Lez7aIaAAAAAN6qZG2343c252bv66b7yn5m8m6" + }, + "int_value": 3, + "double_value": 3.5, + "boolean_value": true, + "string_list": ["hello", "world"], + "int_list": [1, 23, 5], + "bool_list": [false, true, true], + "custom_list": [ + { + "value_1": "hello" + }, + { + "value_1": "world" + } + ] +} +``` + +the generator creates + +> config.g.dart + +```dart +enum Env { dev, prd } + +class Config { + Config._(); + + static final instance = Config._(); + + late String baseUrl; + + late _CustomClass customClass; + + late int intValue; + + late double doubleValue; + + late bool booleanValue; + + late List stringList; + + late List intList; + + late List boolList; + + late List<_CustomList> customList; + + Future init(Env env) async { + String path = ''; + switch (env) { + case Env.dev: + path = 'assets/config/dev.json'; + break; + case Env.prd: + path = 'assets/config/prd.json'; + break; + } + final jsonString = await rootBundle.loadString(path); + final config = json.decode(jsonString) as Map; + baseUrl = config['base_url'] as String; + customClass = + _CustomClass.fromJson(config['custom_class'] as Map); + intValue = config['int_value'] as int; + doubleValue = config['double_value'] as double; + booleanValue = config['boolean_value'] as bool; + stringList = (config['string_list'] as List).cast(); + intList = (config['int_list'] as List).cast(); + boolList = (config['bool_list'] as List).cast(); + customList = _CustomList.listFromJson(config['custom_list'] as List); + } +} + +class _CustomClass { + const _CustomClass({required this.value1, required this.value2}); + + factory _CustomClass.fromJson(Map customClass) => + _CustomClass( + value1: customClass['value_1'] as String, + value2: customClass['value_2'] as String, + ); + + final String value1; + + final String value2; +} + +class _CustomList { + const _CustomList({required this.value1}); + + factory _CustomList.fromJson(Map customList) => _CustomList( + value1: customList['value_1'] as String, + ); + + final String value1; + + static List<_CustomList> listFromJson(List data) => + data.map((e) => _CustomList.fromJson(e as Map)).toList(); +} +``` + +To use the generated configuration you need to call `init` method to initialize all values. Could be called in `main` defining the Environment that you want to use. + +> main.dart + +```dart +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + await Config.instance.init(Env.dev); + runApp(const MyApp()); +} +``` + +Now can access to fields easy using the `instance` + +```dart +Config.instance.baseUrl; +Config.instance.customClass.value1; +``` + +# Supported data types + +```dart +- String +- int +- double +- bool +- CustomClass +- List +- List +- List +- List +- List +``` + +Also can create nested classes! + +**Important** + +- Do not support nullable values +- Do not support dynamic values + +# Run the generator + +To run the generator use one of the next commands: + +- `flutter pub run build_runner build` +- `dart pub run build_runner build` + +## Considerations + +Can also create a config with only one `Environment`. If that so, the `Enum` do not be created and when call `init` method, do not need to pass any argument. + +It is important to known that the generator takes the first value in lists to known the type of the list, so be caferull + +[json_config_generator]: https://pub.dev/packages/json_config_generator +[json_config_annotation]: https://pub.dev/packages/json_config_annotation +[build_runner]: https://pub.dev/packages/build_runner diff --git a/build.yaml b/build.yaml new file mode 100644 index 0000000..86a5b16 --- /dev/null +++ b/build.yaml @@ -0,0 +1,9 @@ +builders: + json_config_generator: + target: ":json_config_annotations" + import: "package:json_config_generator/json_config_generator.dart" + builder_factories: ["configBuilder"] + build_extensions: { ".dart": [".json_config_generator.g.part"] } + auto_apply: dependents + build_to: cache + applies_builders: ["source_gen|combining_builder"] diff --git a/lib/json_config_generator.dart b/lib/json_config_generator.dart new file mode 100644 index 0000000..08dd396 --- /dev/null +++ b/lib/json_config_generator.dart @@ -0,0 +1,8 @@ +import 'package:build/build.dart' show Builder, BuilderOptions; +import 'package:json_config_generator/src/generator.dart'; +import 'package:source_gen/source_gen.dart'; + +Builder configBuilder(BuilderOptions options) { + return SharedPartBuilder( + const [JsonConfigGenerator()], 'json_config_generator'); +} diff --git a/lib/src/generator.dart b/lib/src/generator.dart new file mode 100644 index 0000000..6034731 --- /dev/null +++ b/lib/src/generator.dart @@ -0,0 +1,296 @@ +import 'package:analyzer/dart/constant/value.dart'; +import 'package:analyzer/dart/element/element.dart'; +import 'package:build/build.dart'; +import 'package:built_collection/built_collection.dart'; +import 'package:json_config_generator/src/utils.dart'; +import 'package:source_gen/source_gen.dart'; +import 'package:json_config_annotation/json_config_annotation.dart'; +import 'dart:io'; +import 'dart:convert'; +import 'package:recase/recase.dart'; +import 'package:code_builder/code_builder.dart'; +import 'package:dart_style/dart_style.dart'; + +class JsonConfigGenerator extends GeneratorForAnnotation { + const JsonConfigGenerator(); + + @override + generateForAnnotatedElement( + Element element, ConstantReader annotation, BuildStep buildStep) async { + throwIf( + !element.displayName.startsWith('\$'), 'class name must start with \$', + element: element); + final className = element.displayName.replaceAll('\$', ''); + final environmentEnumName = + annotation.read('environmentEnumName').stringValue; + final environments = annotation.read('environments').listValue; + throwIf(environments.isEmpty, 'environments could not be empty', + element: element); + final environmentMap = generateEnvironmentMap(environments, element); + final path = environmentMap.values.first; + final Map config = await getConfigMap(path); + if (environments.length == 1) { + return mainClassGenerator(className, config, configFile: path); + } else { + throwIf(environmentEnumName.isEmpty, + 'environemntEnumName could not be empty'); + final environmentEnum = + environmentEnumGenerator(environmentEnumName, environmentMap); + return stringConverter(environmentEnum) + + mainClassGenerator(className, config, + environmentName: environmentEnumName, + environmentMap: environmentMap); + } + } + + Future> getConfigMap(String path) async { + final jsonString = await File(path).readAsString(); + throwIf(jsonString.isEmpty, 'file in path "$path" could not be empty'); + Map map = await json.decode(jsonString); + throwIf(map.isEmpty, 'file in path "$path" do not have fields'); + return map; + } + + Map generateEnvironmentMap( + List environments, Element element) { + final map = Map(); + environments.forEach((env) { + final name = env.getField('name')?.toStringValue(); + throwIf(name == null || name.trim().isEmpty, + 'name could not be empty in $env', + element: element); + final path = env.getField('path')?.toStringValue(); + throwIf(path == null || path.trim().isEmpty, + 'path could not be empty in $env', + element: element); + throwIf( + map.containsKey(name), + 'every environment need to have distinct name. ' + '$name was already used', + element: element); + final fileName = path!.split('/').last; + throwIf(!fileName.contains('.json'), + 'environment file "$fileName" must have extension ".json"', + element: element); + map[name!] = path; + }); + return map; + } + + Enum environmentEnumGenerator(String enumName, Map map) { + final builder = EnumBuilder() + ..name = enumName + ..values = + ListBuilder(map.keys.map((e) => EnumValue((v) => v..name = e))); + return builder.build(); + } + + String mainClassGenerator(String name, Map config, + {String? environmentName, + Map? environmentMap, + String? configFile}) { + String initMethodBody = ''; + if (configFile != null) { + initMethodBody += + ''' + final jsonString = await rootBundle.loadString('$configFile'); + final ${name.camelCase} = json.decode(jsonString) as Map; + '''; + } else { + initMethodBody = + ''' + String path = ''; + switch(${environmentName!.camelCase}){ + '''; + environmentMap!.entries.forEach((entry) => initMethodBody += + ''' + case ${environmentName}.${entry.key}: + path = '${entry.value}'; + break; + '''); + initMethodBody += + ''' + } + final jsonString = await rootBundle.loadString(path); + final ${name.camelCase} = json.decode(jsonString) as Map; + '''; + } + + String subClasses = ''; + config.forEach((key, value) { + final type = getType(key, value); + if (value is Map) { + subClasses += subClassesGenerator(key, value); + initMethodBody += + '${key.camelCase}= _${key.pascalCase}.fromJson(${name.camelCase}[\'${key.snakeCase}\'] as Map);'; + } else if (type == 'List<_${key.pascalCase}>') { + subClasses += subClassesGenerator(key, value); + initMethodBody += + '${key.camelCase}= _${key.pascalCase}.listFromJson(${name.camelCase}[\'${key.snakeCase}\'] as List);'; + } else if (type.contains('List')) { + final castType = type.replaceAll('List', ''); + initMethodBody += + '${key.camelCase}= (${name.camelCase}[\'${key.snakeCase}\'] as List).cast$castType();'; + } else { + initMethodBody += + '${key.camelCase}= ${name.camelCase}[\'${key.snakeCase}\'] as $type;'; + } + }); + final mainClass = Class( + (c) => c + ..name = name + ..constructors.addAll([Constructor((c) => c..name = '_')]) + ..fields.addAll([ + Field((f) => f + ..name = 'instance' + ..static = true + ..modifier = FieldModifier.final$ + ..assignment = Code('$name._()')), + ...config + .map((key, value) { + return MapEntry( + key, + Field((f) => f + ..name = key.camelCase + ..late = true + ..type = refer(getType(key, value))), + ); + }) + .values + .toList() + ]) + ..methods.addAll( + [ + Method((m) => m + ..name = 'init' + ..modifier = MethodModifier.async + ..requiredParameters = ListBuilder([ + if (environmentName != null && environmentMap != null) + Parameter((p) => p + ..name = environmentName.camelCase + ..type = refer(environmentName)) + ]) + ..body = Code(initMethodBody) + ..returns = refer('Future')) + ], + ), + ); + return stringConverter(mainClass) + subClasses; + } + + String subClassesGenerator(String name, dynamic values) { + Map data; + String subClasses = ''; + bool isList = false; + if (getType(name, values) == 'List<_${name.pascalCase}>') { + data = (values as List).first; + isList = true; + } else if (values is Map) { + data = values; + } else { + return ''; + } + String factoryCode = ''; + factoryCode += '_${name.pascalCase}('; + data.forEach((key, value) { + final type = getType(key, value); + if (value is Map) { + subClasses += subClassesGenerator(key, value); + factoryCode += + '${key.camelCase}: _${key.pascalCase}.fromJson(${name.camelCase}[\'${key.snakeCase}\'] as Map),'; + } else if (type == 'List<_${key.pascalCase}>') { + subClasses += subClassesGenerator(key, value); + factoryCode += + '${key.camelCase}: _${key.pascalCase}.listFromJson(${name.camelCase}[\'${key.snakeCase}\'] as List),'; + } else if (type.contains('List')) { + final castType = type.replaceAll('List', ''); + factoryCode += + '${key.camelCase}= (${name.camelCase}[\'${key.snakeCase}\'] as List).cast$castType();'; + } else { + factoryCode += + '${key.camelCase}: ${name.camelCase}[\'${key.snakeCase}\'] as $type,'; + } + }); + factoryCode += ')'; + final subclass = Class( + (c) => c + ..name = '_${name.pascalCase}' + ..fields.addAll(data + .map( + (key, value) => MapEntry( + key, + Field((f) => f + ..name = key.camelCase + ..type = refer(getType(key, value)) + ..modifier = FieldModifier.final$)), + ) + .values + .toList()) + ..constructors.addAll( + [ + Constructor((e) => e + ..constant = true + ..optionalParameters.addAll(data + .map( + (key, value) => MapEntry( + key, + Parameter((f) => f + ..name = key.camelCase + ..toThis = true + ..named = true + ..required = true), + ), + ) + .values + .toList())), + Constructor((e) => e + ..factory = true + ..name = 'fromJson' + ..lambda = true + ..requiredParameters.add(Parameter( + (p) => p + ..type = refer('Map') + ..name = name.camelCase, + )) + ..body = Code(factoryCode)), + ], + ) + ..methods.addAll([ + if (isList) + Method((m) => m + ..static = true + ..returns = refer('List<_${name.pascalCase}>') + ..name = 'listFromJson' + ..requiredParameters.add( + Parameter((p) => p + ..name = 'data' + ..type = refer('List')), + ) + ..lambda = true + ..body = Code( + 'data.map((e) => _${name.pascalCase}.fromJson(e as Map)).toList()')) + ]), + ); + return stringConverter(subclass) + subClasses; + } + + String getType(String key, dynamic value) { + if (value is String) + return 'String'; + else if (value is bool) + return 'bool'; + else if (value is int) + return 'int'; + else if (value is double) + return 'double'; + else if (value is List) + return 'List<${getType(key, value.first)}>'; + else + return '_${key.pascalCase}'; + } + + String stringConverter(Spec obj) { + final emitter = DartEmitter(useNullSafetySyntax: true); + return DartFormatter().format(obj.accept(emitter).toString()); + } +} diff --git a/lib/src/utils.dart b/lib/src/utils.dart new file mode 100644 index 0000000..af2913f --- /dev/null +++ b/lib/src/utils.dart @@ -0,0 +1,23 @@ +import 'package:analyzer/dart/element/element.dart'; + +void throwIf(bool condition, String message, {Element? element}) { + if (condition) throwError(message, element: element); +} + +void throwError(String message, {Element? element}) { + throw InvalidGenerationSourceError( + message, + element: element, + ); +} + +class InvalidGenerationSourceError implements Exception { + final String message; + final Element? element; + + const InvalidGenerationSourceError(this.message, {this.element}); + + @override + String toString() => + '[ERROR] $message ${element != null ? 'in $element' : ''}'; +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..0f5d4ec --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,271 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + url: "https://pub.dartlang.org" + source: hosted + version: "27.0.0" + analyzer: + dependency: "direct main" + description: + name: analyzer + url: "https://pub.dartlang.org" + source: hosted + version: "2.4.0" + args: + dependency: transitive + description: + name: args + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + async: + dependency: transitive + description: + name: async + url: "https://pub.dartlang.org" + source: hosted + version: "2.5.0" + build: + dependency: "direct main" + description: + name: build + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.1" + build_config: + dependency: "direct main" + description: + name: build_config + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + built_collection: + dependency: "direct main" + description: + name: built_collection + url: "https://pub.dartlang.org" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + url: "https://pub.dartlang.org" + source: hosted + version: "8.0.5" + charcode: + dependency: transitive + description: + name: charcode + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.1" + cli_util: + dependency: transitive + description: + name: cli_util + url: "https://pub.dartlang.org" + source: hosted + version: "0.3.0" + code_builder: + dependency: "direct main" + description: + name: code_builder + url: "https://pub.dartlang.org" + source: hosted + version: "4.1.0" + collection: + dependency: transitive + description: + name: collection + url: "https://pub.dartlang.org" + source: hosted + version: "1.15.0" + convert: + dependency: transitive + description: + name: convert + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0" + crypto: + dependency: transitive + description: + name: crypto + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.1" + dart_style: + dependency: "direct main" + description: + name: dart_style + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + url: "https://pub.dartlang.org" + source: hosted + version: "6.1.0" + fixnum: + dependency: transitive + description: + name: fixnum + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + glob: + dependency: transitive + description: + name: glob + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.1" + json_annotation: + dependency: transitive + description: + name: json_annotation + url: "https://pub.dartlang.org" + source: hosted + version: "4.0.1" + json_config_annotation: + dependency: "direct main" + description: + name: json_config_annotation + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.0" + logging: + dependency: transitive + description: + name: logging + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" + matcher: + dependency: transitive + description: + name: matcher + url: "https://pub.dartlang.org" + source: hosted + version: "0.12.10" + meta: + dependency: transitive + description: + name: meta + url: "https://pub.dartlang.org" + source: hosted + version: "1.7.0" + package_config: + dependency: transitive + description: + name: package_config + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + path: + dependency: transitive + description: + name: path + url: "https://pub.dartlang.org" + source: hosted + version: "1.8.0" + pedantic: + dependency: transitive + description: + name: pedantic + url: "https://pub.dartlang.org" + source: hosted + version: "1.11.0" + pub_semver: + dependency: transitive + description: + name: pub_semver + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + recase: + dependency: "direct main" + description: + name: recase + url: "https://pub.dartlang.org" + source: hosted + version: "4.0.0" + source_gen: + dependency: "direct main" + description: + name: source_gen + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.1" + source_span: + dependency: transitive + description: + name: source_span + url: "https://pub.dartlang.org" + source: hosted + version: "1.8.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + url: "https://pub.dartlang.org" + source: hosted + version: "1.10.0" + string_scanner: + dependency: transitive + description: + name: string_scanner + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.0" + typed_data: + dependency: transitive + description: + name: typed_data + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.0" + watcher: + dependency: transitive + description: + name: watcher + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + yaml: + dependency: transitive + description: + name: yaml + url: "https://pub.dartlang.org" + source: hosted + version: "3.1.0" +sdks: + dart: ">=2.14.0 <3.0.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..a35cb81 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,20 @@ +name: json_config_generator +description: Configuration generator from json files. +version: 0.1.0 +homepage: https://dev.azure.com/fcorp-infraestructura-servicios-ti/Packages/_git/Flutter.ConfigGenerator + +environment: + sdk: ">=2.12.0 <3.0.0" + +dependencies: + analyzer: ^2.4.0 + build: ^2.1.1 + dart_style: ^2.2.0 + source_gen: ^1.1.1 + code_builder: ^4.1.0 + build_config: ^1.0.0 + built_collection: ^5.1.1 + json_config_annotation: ^0.1.0 + recase: ^4.0.0 + +dev_dependencies: