diff --git a/swagger_parser/CHANGELOG.md b/swagger_parser/CHANGELOG.md index 2d7c13ac..0954ecd2 100644 --- a/swagger_parser/CHANGELOG.md +++ b/swagger_parser/CHANGELOG.md @@ -1,9 +1,15 @@ +## 1.10.0 +- Support for generating schemas by url (see [example](https://github.com/Carapacik/swagger_parser/blob/main/swagger_parser/example/swagger_parser.yaml)) +- Add new config parameter `schema_url` +- Add new config parameter `schema_from_url_to_file` +- Add new config parameter `prefer_schema_source` + ## 1.9.2 - Fix error with `required` in clients ([#101](https://github.com/Carapacik/swagger_parser/issues/103)) ## 1.9.1 - Handling incorrect names for classes, enums and methods. -- Additional name for unnamed models [#98](https://github.com/Carapacik/swagger_parser/issues/98) +- Additional name for unnamed models ([#98](https://github.com/Carapacik/swagger_parser/issues/98)) - Support for `deprecated` annotations for methods ## 1.9.0 @@ -13,7 +19,7 @@ - Fix error with missing File import ([#101](https://github.com/Carapacik/swagger_parser/issues/101)) ## 1.8.0 -- Multiple schemas support(see ([example](https://github.com/Carapacik/swagger_parser/blob/main/swagger_parser/example/swagger_parser.yaml))) +- Multiple schemas support (see [example](https://github.com/Carapacik/swagger_parser/blob/main/swagger_parser/example/swagger_parser.yaml)) - Support for specifying nullable types via anyOf - Edit root client template - Add new config parameter `root_client_name` diff --git a/swagger_parser/README.md b/swagger_parser/README.md index 4cf3bcbd..2e0a4d16 100644 --- a/swagger_parser/README.md +++ b/swagger_parser/README.md @@ -7,12 +7,14 @@ [![Tests](https://github.com/Carapacik/swagger_parser/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/Carapacik/swagger_parser/actions/workflows/tests.yml) -## Dart package that generates REST clients and data classes from OpenApi definition file +## Dart package that generates REST clients and data classes from OpenApi definition files or links ## Features - Supports OpenApi v2, v3.0 and v3.1 - Support JSON and YAML format +- Support for generation by link +- Support for multiple schemes - Generate REST client files based on Retrofit - Generate data classes (also on [freezed](https://pub.dev/packages/freezed)) - Support for multiple languages (Dart, Kotlin) @@ -46,8 +48,12 @@ An example of YAML is shown below. A default value is specified for each of the ```yaml swagger_parser: - # Required. Sets the OpenApi schema path directory for api definition. + # You must provide the file path and/or url to the OpenApi schema. + + # Sets the OpenApi schema path directory for api definition. schema_path: schemas/openapi.json + # Sets the url of the OpenApi schema + schema_url: https://petstore.swagger.io/v2/swagger.json # Required. Sets output directory for generated files (Clients and DTOs). output_directory: lib/api @@ -56,6 +62,10 @@ swagger_parser: # Current available languages are: dart, kotlin language: dart + # Optional. If 'schema_path' and 'schema_url' are specified, what will be used. + # Current available options are: path, url. + prefer_schema_source: url + # Optional (dart only). Set 'true' to generate data classes using freezed package. freezed: false @@ -79,6 +89,9 @@ swagger_parser: # Optional. Set to 'true' to squash all clients in one client. squash_clients: false + # Optional. Set to 'false' to not write the schema from the url to the schema file. + schema_from_url_to_file: true + # Optional. Set postfix for Client class and file. client_postfix: Client @@ -123,14 +136,14 @@ swagger_parser: put_in_folder: true replacement_rules: [] - - schema_path: schemas/openapi.json + - schema_url: https://petstore.swagger.io/v2/swagger.json name: pet_service - client_postfix: DataSource + client_postfix: Service put_clients_in_folder: true - enums_to_json: true put_in_folder: true - - schema_path: schemas/openapi.json + - schema_path: schemas/pet_store.json + schema_url: https://petstore.swagger.io/v2/swagger.json output_directory: lib/api/kotlin language: kotlin ``` diff --git a/swagger_parser/example/swagger_parser.yaml b/swagger_parser/example/swagger_parser.yaml index 759d476d..dba9b598 100644 --- a/swagger_parser/example/swagger_parser.yaml +++ b/swagger_parser/example/swagger_parser.yaml @@ -1,14 +1,22 @@ swagger_parser: - # Required. Sets the OpenApi schema path directory for api definition. + # You must provide the file path and/or url to the OpenApi schema. + + # Sets the OpenApi schema path directory for api definition. # schema_path: schemas/openapi.json + # Sets the url of the OpenApi schema + # schema_url: https://petstore.swagger.io/v2/swagger.json # Required. Sets output directory for generated files (Clients and DTOs). output_directory: lib/api # Optional. Sets the programming language. - # Current available languages are: dart, kotlin + # Current available languages are: dart, kotlin. language: dart + # Optional. If 'schema_path' and 'schema_url' are specified, what will be used. + # Current available options are: path, url. + prefer_schema_source: url + # Optional (dart only). Set 'true' to generate data classes using freezed package. freezed: false @@ -32,6 +40,9 @@ swagger_parser: # Optional. Set to 'true' to squash all clients in one client. squash_clients: false + # Optional. Set to 'false' to not write the schema from the url to the schema file. + schema_from_url_to_file: true + # Optional. Set postfix for Client class and file. client_postfix: Client @@ -67,13 +78,13 @@ swagger_parser: put_in_folder: true replacement_rules: [] - - schema_path: schemas/openapi.json + - schema_url: https://petstore.swagger.io/v2/swagger.json name: pet_service - client_postfix: DataSource + client_postfix: Service put_clients_in_folder: true - enums_to_json: true put_in_folder: true - - schema_path: schemas/openapi.json + - schema_path: schemas/pet_store.json + schema_url: https://petstore.swagger.io/v2/swagger.json output_directory: lib/api/kotlin language: kotlin diff --git a/swagger_parser/lib/src/config/yaml_config.dart b/swagger_parser/lib/src/config/yaml_config.dart index ceb189a2..94274c44 100644 --- a/swagger_parser/lib/src/config/yaml_config.dart +++ b/swagger_parser/lib/src/config/yaml_config.dart @@ -1,6 +1,8 @@ import 'package:args/args.dart'; +import 'package:collection/collection.dart'; import 'package:yaml/yaml.dart'; +import '../generator/models/prefer_schema_source.dart'; import '../generator/models/programming_language.dart'; import '../generator/models/replacement_rule.dart'; import '../utils/file_utils.dart'; @@ -17,9 +19,12 @@ import 'config_exception.dart'; final class YamlConfig { /// Applies parameters directly from constructor const YamlConfig({ - required this.schemaPath, - required this.outputDirectory, required this.name, + required this.outputDirectory, + this.schemaPath, + this.schemaUrl, + this.schemaFromUrlToFile, + this.preferSchemaSource, this.language, this.freezed, this.rootClient, @@ -48,9 +53,19 @@ final class YamlConfig { schemaPath = ''; } - if (schemaPath == null) { + final schemaUrl = yamlConfig['schema_url']?.toString(); + if (schemaUrl != null) { + final uri = Uri.tryParse(schemaUrl); + if (uri == null) { + throw const ConfigException( + "Config parameter 'schema_url' must be valid URL.", + ); + } + } + + if (schemaPath == null && schemaUrl == null) { throw const ConfigException( - "Config parameter 'schema_path' is required.", + "Config parameters 'schema_path' or 'schema_url' are required.", ); } @@ -73,13 +88,48 @@ final class YamlConfig { outputDirectory = rootConfig.outputDirectory; } + final rawName = yamlConfig['name']; + if (rawName is! String?) { + throw const ConfigException( + "Config parameter 'name' must be String.", + ); + } + + final name = rawName == null || rawName.isEmpty + ? (schemaPath ?? schemaUrl)! + .split('/') + .lastOrNull + ?.split('.') + .firstOrNull ?? + 'unknown' + : rawName; + + final schemaFromUrlToFile = yamlConfig['schema_from_url_to_file']; + if (schemaFromUrlToFile is! bool?) { + throw const ConfigException( + "Config parameter 'schema_from_url_to_file' must be bool.", + ); + } + + PreferSchemaSource? preferSchemaSource; + final rawPreferSchemeSource = + yamlConfig['prefer_schema_source']?.toString(); + if (rawPreferSchemeSource != null) { + preferSchemaSource = PreferSchemaSource.fromString(rawPreferSchemeSource); + if (preferSchemaSource == null) { + throw ConfigException( + "'prefer_schema_source' field must be contained in ${PreferSchemaSource.values.map((e) => e.name)}.", + ); + } + } + ProgrammingLanguage? language; final rawLanguage = yamlConfig['language']?.toString(); if (rawLanguage != null) { language = ProgrammingLanguage.fromString(rawLanguage); if (language == null) { throw ConfigException( - "'language' field must be contained in ${ProgrammingLanguage.values}.", + "'language' field must be contained in ${ProgrammingLanguage.values.map((e) => e.name)}.", ); } } @@ -117,6 +167,13 @@ final class YamlConfig { ); } + final putInFolder = yamlConfig['put_in_folder']; + if (putInFolder is! bool?) { + throw const ConfigException( + "Config parameter 'put_in_folder' must be bool.", + ); + } + final squashClients = yamlConfig['squash_clients']; if (squashClients is! bool?) { throw const ConfigException( @@ -180,37 +237,23 @@ final class YamlConfig { } } - final putInFolder = yamlConfig['put_in_folder']; - if (putInFolder is! bool?) { - throw const ConfigException( - "Config parameter 'put_in_folder' must be bool.", - ); - } - - final rawName = yamlConfig['name']; - if (rawName is! String?) { - throw const ConfigException( - "Config parameter 'name' must be String.", - ); - } - - final name = rawName == null || rawName.isEmpty - ? schemaPath.split('/').last.split('.').first - : rawName; - return YamlConfig( + name: name, schemaPath: schemaPath, outputDirectory: outputDirectory, - name: name, + schemaUrl: schemaUrl, + schemaFromUrlToFile: + schemaFromUrlToFile ?? rootConfig?.schemaFromUrlToFile, + preferSchemaSource: preferSchemaSource ?? rootConfig?.preferSchemaSource, language: language ?? rootConfig?.language, freezed: freezed ?? rootConfig?.freezed, rootClient: rootClient ?? rootConfig?.rootClient, rootClientName: rootClientName ?? rootConfig?.rootClientName, clientPostfix: clientPostfix ?? rootConfig?.clientPostfix, + putInFolder: putInFolder ?? rootConfig?.putInFolder, putClientsInFolder: putClientsInFolder ?? rootConfig?.putClientsInFolder, squashClients: squashClients ?? rootConfig?.squashClients, pathMethodName: pathMethodName ?? rootConfig?.pathMethodName, - putInFolder: putInFolder ?? rootConfig?.putInFolder, enumsToJson: enumsToJson ?? rootConfig?.enumsToJson, enumsPrefix: enumsPrefix ?? rootConfig?.enumsPrefix, markFilesAsGenerated: @@ -249,17 +292,19 @@ final class YamlConfig { final configs = []; final schemaPath = yamlMap['schema_path'] as String?; + final schemaUrl = yamlMap['schema_url'] as String?; final schemas = yamlMap['schemas'] as YamlList?; - if (schemas == null && schemaPath == null) { + if (schemas == null && schemaUrl == null && schemaPath == null) { throw const ConfigException( - "Config parameter 'schema_path' or 'schemas' is required.", + "Config parameter 'schema_path', 'schema_url' or 'schemas' is required.", ); } - if (schemas != null && schemaPath != null) { + if (schemas != null && schemaPath != null || + schemas != null && schemaUrl != null) { throw const ConfigException( - "Config parameter 'schema_path' and 'schemas' can't be used together.", + "Config parameter 'schema_path' or 'schema_url' can't be used with 'schemas'.", ); } @@ -290,16 +335,19 @@ final class YamlConfig { } final String name; - final String schemaPath; final String outputDirectory; + final String? schemaPath; + final String? schemaUrl; + final bool? schemaFromUrlToFile; + final PreferSchemaSource? preferSchemaSource; final ProgrammingLanguage? language; final bool? freezed; final String? clientPostfix; final bool? rootClient; final String? rootClientName; - final bool? putClientsInFolder; final bool? squashClients; final bool? pathMethodName; + final bool? putClientsInFolder; final bool? putInFolder; final bool? enumsToJson; final bool? enumsPrefix; diff --git a/swagger_parser/lib/src/generator/generator.dart b/swagger_parser/lib/src/generator/generator.dart index cc9ac8dd..ab13ef9d 100644 --- a/swagger_parser/lib/src/generator/generator.dart +++ b/swagger_parser/lib/src/generator/generator.dart @@ -1,14 +1,18 @@ +import 'dart:convert'; + import 'package:path/path.dart' as p; import '../config/yaml_config.dart'; import '../parser/parser.dart'; import '../utils/case_utils.dart'; import '../utils/file_utils.dart'; +import '../utils/utils.dart'; import 'fill_controller.dart'; import 'generator_exception.dart'; import 'models/generated_file.dart'; import 'models/generation_statistics.dart'; import 'models/open_api_info.dart'; +import 'models/prefer_schema_source.dart'; import 'models/programming_language.dart'; import 'models/replacement_rule.dart'; import 'models/universal_data_class.dart'; @@ -21,11 +25,15 @@ final class Generator { /// Applies parameters directly from constructor /// and sets them to default if not found Generator({ - required String schemaContent, required String outputDirectory, + String? schemaPath, + String? schemaUrl, + String? schemaContent, + bool? isYaml, + bool? schemaFromUrlToFile, + PreferSchemaSource? preferSchemeSource, ProgrammingLanguage? language, String? name, - bool? isYaml, bool? freezed, bool? rootClient, String? clientPostfix, @@ -38,11 +46,15 @@ final class Generator { bool? enumsPrefix, bool? markFilesAsGenerated, List? replacementRules, - }) : _schemaContent = schemaContent, + }) : _schemaPath = schemaPath, + _schemaUrl = schemaUrl, + _schemaContent = schemaContent, + _isYaml = isYaml ?? false, + _schemaFromUrlToFile = schemaFromUrlToFile ?? true, + _preferSchemeSource = preferSchemeSource ?? PreferSchemaSource.url, _outputDirectory = outputDirectory, _name = name, _programmingLanguage = language ?? ProgrammingLanguage.dart, - _isYaml = isYaml ?? false, _freezed = freezed ?? false, _rootClient = rootClient ?? true, _rootClientName = rootClientName ?? 'RestClient', @@ -58,21 +70,14 @@ final class Generator { /// Applies parameters set from yaml config file factory Generator.fromYamlConfig(YamlConfig yamlConfig) { - final schemaPath = yamlConfig.schemaPath; - final configFile = schemaFile(schemaPath); - if (configFile == null) { - throw GeneratorException("Can't find schema file at $schemaPath."); - } - - final isYaml = p.extension(schemaPath).toLowerCase() == '.yaml'; - final schemaContent = configFile.readAsStringSync(); - return Generator( - schemaContent: schemaContent, outputDirectory: yamlConfig.outputDirectory, + schemaPath: yamlConfig.schemaPath, + schemaUrl: yamlConfig.schemaUrl, + schemaFromUrlToFile: yamlConfig.schemaFromUrlToFile, + preferSchemeSource: yamlConfig.preferSchemaSource, language: yamlConfig.language, name: yamlConfig.name, - isYaml: isYaml, freezed: yamlConfig.freezed, rootClient: yamlConfig.rootClient, rootClientName: yamlConfig.rootClientName, @@ -89,10 +94,22 @@ final class Generator { } /// The contents of your schema file - final String _schemaContent; + String? _schemaContent; /// Is the schema format YAML - final bool _isYaml; + bool _isYaml; + + /// The path to your schema file + final String? _schemaPath; + + /// The url to your schema file + final String? _schemaUrl; + + /// If true, schema will be extracted from url and saved to file + final bool _schemaFromUrlToFile; + + /// Prefer schema from url or file + final PreferSchemaSource _preferSchemeSource; /// Output directory final String _outputDirectory; @@ -155,6 +172,7 @@ final class Generator { Future<(OpenApiInfo, GenerationStatistics)> generateFiles() async { final stopwatch = Stopwatch()..start(); + await _fetchSchemaContent(); _parseOpenApiDefinitionFile(); await _generateFiles(); @@ -177,15 +195,62 @@ final class Generator { /// Generates content of files based on OpenApi definition file /// and return list of [GeneratedFile] Future> generateContent() async { + await _fetchSchemaContent(); _parseOpenApiDefinitionFile(); return _fillContent(); } + Future _fetchSchemaContent() async { + final url = _schemaUrl; + final path = _schemaPath; + + if ((_preferSchemeSource == PreferSchemaSource.url || path == null) && + url != null) { + final extension = p.extension(url).toLowerCase(); + _isYaml = switch (extension) { + '.yaml' => true, + '.json' => false, + _ => throw GeneratorException( + 'Unsupported $url extension: $extension', + ), + }; + extractingSchemaFromUrlMessage(url); + _schemaContent = await schemaFromUrl(url); + if (_schemaFromUrlToFile && path != null) { + if (!_isYaml) { + final formattedJson = const JsonEncoder.withIndent(' ') + .convert(jsonDecode(_schemaContent!)); + writeSchemaToFile(formattedJson, path); + } else { + writeSchemaToFile(_schemaContent!, path); + } + } + } else if (path != null) { + final configFile = schemaFile(path); + if (configFile == null) { + throw GeneratorException("Can't find schema file at $path."); + } + final extension = p.extension(path).toLowerCase(); + _isYaml = switch (extension) { + '.yaml' => true, + '.json' => false, + _ => throw GeneratorException( + 'Unsupported $path extension: $extension', + ), + }; + _schemaContent = configFile.readAsStringSync(); + } else if (_schemaContent == null) { + throw GeneratorException( + "Parameters 'schemaPath' or 'schemaUrl' or 'schemaContent' are required", + ); + } + } + /// Parse definition file content and fill list of [UniversalRestClient] /// and list of [UniversalDataClass] void _parseOpenApiDefinitionFile() { final parser = OpenApiParser( - _schemaContent, + _schemaContent!, isYaml: _isYaml, pathMethodName: _pathMethodName, enumsPrefix: _enumsPrefix, diff --git a/swagger_parser/lib/src/generator/models/prefer_schema_source.dart b/swagger_parser/lib/src/generator/models/prefer_schema_source.dart new file mode 100644 index 00000000..9a0b7801 --- /dev/null +++ b/swagger_parser/lib/src/generator/models/prefer_schema_source.dart @@ -0,0 +1,16 @@ +import 'package:collection/collection.dart'; + +/// Enum for choosing schema source +enum PreferSchemaSource { + /// Prefer remote schema from url + url, + + /// Prefer local schema from file + path; + + /// Returns [PreferSchemaSource] from string + static PreferSchemaSource? fromString(String string) => + values.firstWhereOrNull( + (e) => e.name == string, + ); +} diff --git a/swagger_parser/lib/src/parser/parser.dart b/swagger_parser/lib/src/parser/parser.dart index baefc1fa..10100205 100644 --- a/swagger_parser/lib/src/parser/parser.dart +++ b/swagger_parser/lib/src/parser/parser.dart @@ -24,15 +24,15 @@ class OpenApiParser { /// and [isYaml] schema format or not OpenApiParser( String fileContent, { + String? name, bool isYaml = false, bool enumsPrefix = false, bool pathMethodName = false, - String? name, bool squashClients = false, List replacementRules = const [], - }) : _pathMethodName = pathMethodName, + }) : _name = name, + _pathMethodName = pathMethodName, _enumsPrefix = enumsPrefix, - _name = name, _squashClients = squashClients, _replacementRules = replacementRules { _definitionFileContent = isYaml diff --git a/swagger_parser/lib/src/utils/file_utils.dart b/swagger_parser/lib/src/utils/file_utils.dart index 8819aa68..1b40b65c 100644 --- a/swagger_parser/lib/src/utils/file_utils.dart +++ b/swagger_parser/lib/src/utils/file_utils.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:io'; import 'package:path/path.dart' as p; @@ -30,6 +31,18 @@ File? schemaFile(String filePath) { return file.existsSync() ? file : null; } +void writeSchemaToFile(String schemaContent, String filePath) { + File(p.join(_rootDirectoryPath, filePath)).writeAsStringSync(schemaContent); +} + +Future schemaFromUrl(String url) async { + final client = HttpClient(); + final request = await client.getUrl(Uri.parse(url)); + final response = await request.close(); + final data = await response.transform(utf8.decoder).join(); + return data; +} + /// Creates DTO file Future generateFile( String outputDirectory, diff --git a/swagger_parser/lib/src/utils/utils.dart b/swagger_parser/lib/src/utils/utils.dart index 8da46b6f..46935fe8 100644 --- a/swagger_parser/lib/src/utils/utils.dart +++ b/swagger_parser/lib/src/utils/utils.dart @@ -10,6 +10,9 @@ import '../utils/case_utils.dart'; const _green = '\x1B[32m'; // ignore: unused_element const _yellow = '\x1B[33m'; +// ignore: unused_element +const _blue = '\x1B[34m'; +const _lightBlue = '\x1B[36m'; const _red = '\x1B[31m'; const _reset = '\x1B[0m'; @@ -78,12 +81,12 @@ const _ignoreLintsComment = ''' void introMessage() { stdout.writeln( - ''' - ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ - ┃ ┃ - ┃ Welcome to swagger_parser ┃ - ┃ ┃ - ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + r''' + +┃ ____ _ _ _ ____ ____ ____ ____ ____ ___ ____ ____ ____ ____ ____ +┃ [__ | | | |__| | __ | __ |___ |__/ |__] |__| |__/ [__ |___ |__/ +┃ ___] |_|_| | | |__] |__] |___ | \ ___ | | | | \ ___] |___ | \ +┃ ''', ); } @@ -92,6 +95,10 @@ void generateMessage() { stdout.writeln('Generate...'); } +void extractingSchemaFromUrlMessage(String url) { + stdout.writeln('Extracting schema from $_lightBlue$url...$_reset'); +} + final _numbersRegExp = RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'); String formatNumber(int number) => '$number'.replaceAllMapped( @@ -112,12 +119,12 @@ void schemaStatisticsMessage({ } stdout.writeln( - '\n> $title $version: \n' + '> $title $version: \n' ' ${formatNumber(statistics.totalRestClients)} rest clients, ' '${formatNumber(statistics.totalRequests)} requests, ' '${formatNumber(statistics.totalDataClasses)} data classes.\n' ' ${formatNumber(statistics.totalFiles)} files with ${formatNumber(statistics.totalLines)} lines of code.\n' - ' ${_green}Success (${statistics.timeElapsed.inMilliseconds / 1000} seconds)$_reset', + ' ${_green}Success (${statistics.timeElapsed.inMilliseconds / 1000} seconds)$_reset\n', ); } @@ -132,10 +139,10 @@ void schemaFailedMessage({ } stdout.writeln( - '\n> $title: \n' + '> $title: \n' ' ${_red}Failed to generate files.$_reset\n' ' $error\n' - ' ${stack.toString().replaceAll('\n', '\n ')}', + ' ${stack.toString().replaceAll('\n', '\n ')}\n', ); } @@ -145,7 +152,7 @@ void summaryStatisticsMessage({ required GenerationStatistics statistics, }) { stdout.writeln( - '\nSummary (${statistics.timeElapsed.inMilliseconds / 1000} seconds):\n' + 'Summary (${statistics.timeElapsed.inMilliseconds / 1000} seconds):\n' '${successCount != schemasCount ? '$successCount/$schemasCount' : '$schemasCount'} schemas, ' '${formatNumber(statistics.totalRestClients)} clients, ' '${formatNumber(statistics.totalRequests)} requests, ' @@ -160,19 +167,16 @@ void doneMessage({ }) { if (successSchemasCount == 0) { stdout.writeln( - '\n' '${_red}The generation was completed with errors.\n' 'No schemas were generated.$_reset', ); } else if (successSchemasCount != schemasCount) { stdout.writeln( - '\n' '${_red}The generation was completed with errors.\n' '${schemasCount - successSchemasCount} schemas were not generated.$_reset', ); } else { stdout.writeln( - '\n' '${schemasCount > 1 ? _green : ''}The generation was completed successfully. ' 'You can run the generation using build_runner.${schemasCount > 1 ? _reset : ''}', );