├── .gitmodules ├── gen_tests ├── tests │ ├── analysis_options.yaml │ ├── README.md │ ├── .gitignore │ ├── pubspec.yaml │ └── test │ │ └── types_test.dart ├── types │ ├── cspell.config.yaml │ ├── .gitignore │ ├── lib │ │ ├── client.dart │ │ ├── api.dart │ │ ├── model │ │ │ ├── date_type.dart │ │ │ ├── email_type.dart │ │ │ └── types200_response.dart │ │ ├── api │ │ │ └── default_api.dart │ │ ├── model_helpers.dart │ │ ├── api_exception.dart │ │ ├── api_client.dart │ │ └── auth.dart │ ├── pubspec.yaml │ └── analysis_options.yaml └── types.json ├── .gitattributes ├── lib ├── templates │ ├── add_imports.mustache │ ├── public_api.mustache │ ├── gitignore.mustache │ ├── cspell.config.mustache │ ├── client.mustache │ ├── pubspec.mustache │ ├── schema_string_newtype.mustache │ ├── schema_number_newtype.mustache │ ├── schema_empty_object.mustache │ ├── schema_one_of.mustache │ ├── analysis_options.mustache │ ├── model_helpers.mustache │ ├── schema_enum.mustache │ ├── api.mustache │ ├── schema_object.mustache │ ├── api_exception.dart │ ├── api_client.dart │ └── auth.dart ├── space_gen.dart └── src │ ├── logger.dart │ ├── render │ ├── templates.dart │ ├── schema_renderer.dart │ ├── tree_visitor.dart │ └── file_renderer.dart │ ├── quirks.dart │ ├── loader.dart │ ├── string.dart │ ├── parse │ ├── visitor.dart │ └── spec.dart │ ├── types.dart │ └── render.dart ├── CHANGELOG.md ├── analysis_options.yaml ├── .gitignore ├── cspell.config.yaml ├── coverage.sh ├── pubspec.yaml ├── test ├── render_test.dart ├── render │ ├── templates_test.dart │ └── render_tree_test.dart ├── types_test.dart ├── parse │ ├── spec_test.dart │ └── visitor_test.dart └── string_test.dart ├── LICENSE ├── .github └── workflows │ └── dart.yml ├── GEMINI.md ├── bin └── space_gen.dart ├── tool └── gen_tests.dart ├── README.md └── pubspec.lock /.gitmodules: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /gen_tests/tests/analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:lints/recommended.yaml 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /lib/templates/add_imports.mustache: -------------------------------------------------------------------------------- 1 | {{#imports}}import '{{{path}}}'{{#asName}} as {{{asName}}}{{/asName}}; 2 | {{/imports}} 3 | {{{content}}} -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 1.0.1 2 | 3 | - Fix finding of template files when run via `dart pub run space_gen`. 4 | 5 | ## 1.0.0 6 | 7 | - Initial version. 8 | -------------------------------------------------------------------------------- /lib/templates/public_api.mustache: -------------------------------------------------------------------------------- 1 | {{#imports}} 2 | import '{{{.}}}'; 3 | {{/imports}} 4 | 5 | {{#exports}} 6 | export '{{{.}}}'; 7 | {{/exports}} 8 | -------------------------------------------------------------------------------- /gen_tests/types/cspell.config.yaml: -------------------------------------------------------------------------------- 1 | $schema: https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json 2 | version: "0.2" 3 | words: 4 | - 5 | -------------------------------------------------------------------------------- /lib/space_gen.dart: -------------------------------------------------------------------------------- 1 | export 'src/logger.dart' show Logger, logger, runWithLogger, setVerboseLogging; 2 | export 'src/quirks.dart' show Quirks; 3 | export 'src/render.dart' show loadAndRenderSpec; 4 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:very_good_analysis/analysis_options.yaml 2 | linter: 3 | rules: 4 | # Disabled while I'm still finding the shape of the api. 5 | public_member_api_docs: false -------------------------------------------------------------------------------- /gen_tests/types/.gitignore: -------------------------------------------------------------------------------- 1 | # https://dart.dev/guides/libraries/private-files 2 | # Created by `dart pub` 3 | .dart_tool/ 4 | .packages 5 | # Typically libraries do not check in pubspec.lock, only applications. 6 | pubspec.lock -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # https://dart.dev/guides/libraries/private-files 2 | # Created by `dart pub` 3 | .dart_tool/ 4 | # Created by `coverage.sh` and dart test --coverage 5 | coverage/ 6 | 7 | # Used for local development 8 | local_dev.yaml -------------------------------------------------------------------------------- /gen_tests/tests/README.md: -------------------------------------------------------------------------------- 1 | Tests for the generated gen_tests packages. 2 | 3 | Eventually we might want to just move these into the package directories 4 | themselves, although currently we delete the generated directory every time. -------------------------------------------------------------------------------- /lib/templates/gitignore.mustache: -------------------------------------------------------------------------------- 1 | # https://dart.dev/guides/libraries/private-files 2 | # Created by `dart pub` 3 | .dart_tool/ 4 | .packages 5 | # Typically libraries do not check in pubspec.lock, only applications. 6 | pubspec.lock -------------------------------------------------------------------------------- /gen_tests/types/lib/client.dart: -------------------------------------------------------------------------------- 1 | import 'package:types/api.dart'; 2 | 3 | class Types { 4 | Types({ApiClient? client}) : client = client ?? ApiClient(); 5 | 6 | final ApiClient client; 7 | 8 | DefaultApi get defaultApi => DefaultApi(client); 9 | } 10 | -------------------------------------------------------------------------------- /lib/templates/cspell.config.mustache: -------------------------------------------------------------------------------- 1 | $schema: https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json 2 | version: "0.2" 3 | {{#hasMisspellings}} 4 | words: 5 | {{#misspellings}} 6 | - {{.}} 7 | {{/misspellings}} 8 | {{/hasMisspellings}} -------------------------------------------------------------------------------- /gen_tests/tests/.gitignore: -------------------------------------------------------------------------------- 1 | # https://dart.dev/guides/libraries/private-files 2 | # Created by `dart pub` 3 | .dart_tool/ 4 | 5 | # Avoid committing pubspec.lock for library packages; see 6 | # https://dart.dev/guides/libraries/private-files#pubspeclock. 7 | pubspec.lock 8 | -------------------------------------------------------------------------------- /gen_tests/tests/pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: tests 2 | description: Tests for the generated gen_tests directory. 3 | version: 1.0.0 4 | publish_to: none 5 | 6 | environment: 7 | sdk: ^3.9.0 8 | 9 | dependencies: 10 | types: 11 | path: ../types 12 | 13 | dev_dependencies: 14 | lints: ^6.0.0 15 | test: ^1.25.6 16 | -------------------------------------------------------------------------------- /gen_tests/types/lib/api.dart: -------------------------------------------------------------------------------- 1 | export 'package:types/api/default_api.dart'; 2 | export 'package:types/api_client.dart'; 3 | export 'package:types/api_exception.dart'; 4 | export 'package:types/model/date_type.dart'; 5 | export 'package:types/model/email_type.dart'; 6 | export 'package:types/model/types200_response.dart'; 7 | -------------------------------------------------------------------------------- /lib/templates/client.mustache: -------------------------------------------------------------------------------- 1 | import 'package:{{packageName}}/api.dart'; 2 | 3 | class {{clientClassName}} { 4 | {{clientClassName}}({ApiClient? client}) : client = client ?? ApiClient(); 5 | 6 | final ApiClient client; 7 | 8 | {{#apis}} 9 | {{apiClassName}} get {{apiName}} => {{apiClassName}}(client); 10 | {{/apis}} 11 | } 12 | -------------------------------------------------------------------------------- /gen_tests/types/pubspec.yaml: -------------------------------------------------------------------------------- 1 | # Autogenerated file. Do not edit by hand. 2 | name: types 3 | version: 1.0.0 4 | 5 | environment: 6 | sdk: '>=3.8.0 <4.0.0' 7 | 8 | dependencies: 9 | collection: ^1.19.0 10 | http: ^1.4.0 11 | meta: ^1.16.0 12 | uri: ^1.0.0 13 | 14 | dev_dependencies: 15 | test: ^1.26.0 16 | very_good_analysis: ^8.0.0 17 | -------------------------------------------------------------------------------- /lib/templates/pubspec.mustache: -------------------------------------------------------------------------------- 1 | # Autogenerated file. Do not edit by hand. 2 | name: {{packageName}} 3 | version: 1.0.0 4 | 5 | environment: 6 | sdk: '>=3.8.0 <4.0.0' 7 | 8 | dependencies: 9 | collection: ^1.19.0 10 | http: ^1.4.0 11 | meta: ^1.16.0 12 | uri: ^1.0.0 13 | 14 | dev_dependencies: 15 | test: ^1.26.0 16 | very_good_analysis: ^8.0.0 17 | -------------------------------------------------------------------------------- /cspell.config.yaml: -------------------------------------------------------------------------------- 1 | $schema: https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json 2 | version: "0.2" 3 | ignorePaths: 4 | - "coverage/**" 5 | - "*.txt" # ignore text files at the root of the project 6 | words: 7 | - spacetraders 8 | - newtype 9 | - eseidel 10 | - cooldown 11 | - petstore 12 | - parseable 13 | - Seidel 14 | - mocktail 15 | - pubspec 16 | -------------------------------------------------------------------------------- /coverage.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | # A script for computing combined coverage for all packages in the repo. 4 | # This can be used for viewing coverage locally in your editor. 5 | 6 | dart pub global activate coverage 7 | dart pub get 8 | dart test --coverage=coverage 9 | dart pub global run coverage:format_coverage --lcov --in=coverage \ 10 | --out=coverage/lcov.info --packages=.dart_tool/package_config.json \ 11 | --report-on=lib \ 12 | --check-ignore 13 | -------------------------------------------------------------------------------- /gen_tests/types/lib/model/date_type.dart: -------------------------------------------------------------------------------- 1 | extension type const DateType._(String value) { 2 | const DateType(this.value); 3 | 4 | factory DateType.fromJson(String json) => DateType(json); 5 | 6 | /// Convenience to create a nullable type from a nullable json object. 7 | /// Useful when parsing optional fields. 8 | static DateType? maybeFromJson(String? json) { 9 | if (json == null) { 10 | return null; 11 | } 12 | return DateType.fromJson(json); 13 | } 14 | 15 | String toJson() => value; 16 | } 17 | -------------------------------------------------------------------------------- /gen_tests/types/lib/model/email_type.dart: -------------------------------------------------------------------------------- 1 | extension type const EmailType._(String value) { 2 | const EmailType(this.value); 3 | 4 | factory EmailType.fromJson(String json) => EmailType(json); 5 | 6 | /// Convenience to create a nullable type from a nullable json object. 7 | /// Useful when parsing optional fields. 8 | static EmailType? maybeFromJson(String? json) { 9 | if (json == null) { 10 | return null; 11 | } 12 | return EmailType.fromJson(json); 13 | } 14 | 15 | String toJson() => value; 16 | } 17 | -------------------------------------------------------------------------------- /lib/templates/schema_string_newtype.mustache: -------------------------------------------------------------------------------- 1 | {{{ doc_comment }}}extension type const {{{ typeName }}}._(String value) { 2 | const {{ typeName }}(this.value); 3 | 4 | factory {{ typeName }}.fromJson(String json) => {{ typeName }}(json); 5 | 6 | /// Convenience to create a nullable type from a nullable json object. 7 | /// Useful when parsing optional fields. 8 | static {{ nullableTypeName }} maybeFromJson(String? json) { 9 | if (json == null) { 10 | return null; 11 | } 12 | return {{ typeName }}.fromJson(json); 13 | } 14 | 15 | String toJson() => value; 16 | } 17 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: space_gen 2 | description: An OpenAPI generator, focused on generating high quality code. 3 | version: 1.0.1 4 | repository: https://github.com/eseidel/space_gen 5 | 6 | environment: 7 | sdk: ^3.8.0 8 | 9 | dependencies: 10 | args: ^2.4.2 11 | collection: ^1.19.1 12 | equatable: ^2.0.7 13 | file: ^7.0.0 14 | http: ^1.4.0 15 | mason_logger: ^0.3.3 16 | meta: ^1.10.0 17 | mustache_template: ^2.0.0 18 | path: ^1.8.3 19 | scoped_deps: ^0.1.0+2 20 | version: ^3.0.2 21 | yaml: ^3.1.3 22 | 23 | dev_dependencies: 24 | lints: ^6.0.0 25 | mocktail: ^1.0.4 26 | test: ^1.21.0 27 | very_good_analysis: ^9.0.0 28 | -------------------------------------------------------------------------------- /lib/templates/schema_number_newtype.mustache: -------------------------------------------------------------------------------- 1 | {{{ doc_comment }}}extension type const {{{ typeName }}}._({{dartType}} value) { 2 | const {{ typeName }}(this.value); 3 | 4 | factory {{ typeName }}.fromJson({{jsonType}} json) => {{ typeName }}(json{{jsonToDartCall}}); 5 | 6 | /// Convenience to create a nullable type from a nullable json object. 7 | /// Useful when parsing optional fields. 8 | static {{ nullableTypeName }} maybeFromJson({{jsonType}}? json) { 9 | if (json == null) { 10 | return null; 11 | } 12 | return {{ typeName }}.fromJson(json); 13 | } 14 | 15 | {{dartType}} toJson() => value; 16 | } 17 | -------------------------------------------------------------------------------- /lib/templates/schema_empty_object.mustache: -------------------------------------------------------------------------------- 1 | {{{ doc_comment }}}@immutable 2 | class {{ typeName }} { 3 | const {{ typeName }}(); 4 | 5 | factory {{ typeName }}.fromJson(Map _) { 6 | return const {{ typeName }}(); 7 | } 8 | 9 | /// Convenience to create a nullable type from a nullable json object. 10 | /// Useful when parsing optional fields. 11 | static {{ nullableTypeName }} maybeFromJson(Map? json) { 12 | if (json == null) { 13 | return null; 14 | } 15 | return {{ typeName }}.fromJson(json); 16 | } 17 | 18 | Map toJson() => const {}; 19 | } 20 | -------------------------------------------------------------------------------- /test/render_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:space_gen/src/render.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | void main() { 5 | test('validatePackageName', () { 6 | expect(() => validatePackageName(''), throwsA(isA())); 7 | expect(() => validatePackageName('123'), throwsA(isA())); 8 | expect(() => validatePackageName('a123'), returnsNormally); 9 | expect(() => validatePackageName('a_123'), returnsNormally); 10 | expect( 11 | () => validatePackageName('MyPackage'), 12 | throwsA(isA()), 13 | ); 14 | expect( 15 | () => validatePackageName('my-package'), 16 | throwsA(isA()), 17 | ); 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /lib/templates/schema_one_of.mustache: -------------------------------------------------------------------------------- 1 | {{{ doc_comment }}}sealed class {{ typeName }} { 2 | static {{ typeName }} fromJson(dynamic jsonArg) { 3 | // Determine which schema to use based on the json. 4 | // TODO(eseidel): Implement this. 5 | throw UnimplementedError('{{ typeName }}.fromJson'); 6 | } 7 | 8 | /// Convenience to create a nullable type from a nullable json object. 9 | /// Useful when parsing optional fields. 10 | static {{ nullableTypeName }} maybeFromJson(dynamic json) { 11 | if (json == null) { 12 | return null; 13 | } 14 | return {{ typeName }}.fromJson(json); 15 | } 16 | 17 | /// Require all subclasses to implement toJson. 18 | dynamic toJson(); 19 | } 20 | -------------------------------------------------------------------------------- /test/render/templates_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:file/local.dart'; 2 | import 'package:space_gen/src/render/templates.dart'; 3 | import 'package:test/test.dart'; 4 | 5 | void main() { 6 | group('TemplateProvider', () { 7 | test('throws an exception if the template does not exist', () { 8 | final templateProvider = TemplateProvider.defaultLocation(); 9 | expect( 10 | () => templateProvider.loadTemplate('does_not_exist'), 11 | throwsA(isA()), 12 | ); 13 | }); 14 | 15 | test('throws exception if directory does not exist', () { 16 | expect( 17 | () => TemplateProvider.fromDirectory( 18 | const LocalFileSystem().directory('does_not_exist'), 19 | ), 20 | throwsA(isA()), 21 | ); 22 | }); 23 | }); 24 | } 25 | -------------------------------------------------------------------------------- /lib/src/logger.dart: -------------------------------------------------------------------------------- 1 | import 'package:mason_logger/mason_logger.dart'; 2 | import 'package:scoped_deps/scoped_deps.dart'; 3 | 4 | export 'package:mason_logger/mason_logger.dart'; 5 | 6 | /// A reference to the global logger using package:scoped to create. 7 | final ScopedRef loggerRef = create(Logger.new); 8 | 9 | /// A getter for the global logger using package:scoped to read. 10 | /// This is a getter so that it cannot be replaced directly, if you wish 11 | /// to mock the logger use runScoped with override values. 12 | Logger get logger => read(loggerRef); 13 | 14 | /// Run [fn] with the global logger replaced with [logger]. 15 | R runWithLogger(Logger logger, R Function() fn) { 16 | return runScoped(fn, values: {loggerRef.overrideWith(() => logger)}); 17 | } 18 | 19 | /// Set the global logger to verbose logging. 20 | void setVerboseLogging() { 21 | logger.level = Level.verbose; 22 | } 23 | -------------------------------------------------------------------------------- /gen_tests/types/analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:very_good_analysis/analysis_options.yaml 2 | analyzer: 3 | errors: 4 | # This triggers even from the same class, which is too much. 5 | # See also https://github.com/dart-lang/sdk/issues/61037 6 | deprecated_member_use_from_same_package: ignore 7 | linter: 8 | rules: 9 | # We do not generate docs yet. 10 | public_member_api_docs: false 11 | # OpenAPI has no deprecation messages. 12 | provide_deprecation_message: false 13 | # Some specs contain code blocks which are not properly annotated. 14 | # There isn't a great solution for us to fix them, so we ignore the lint. 15 | missing_code_block_language_in_doc_comment: false 16 | # When using SCREAMING_CAPS for enum values, we need to disable this rule. 17 | constant_identifier_names: false 18 | # When quirking to have mutable models, we need to disable this rule. 19 | avoid_equals_and_hash_code_on_mutable_classes: false 20 | -------------------------------------------------------------------------------- /gen_tests/types/lib/api/default_api.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | import 'dart:io'; 4 | import 'package:types/api_client.dart'; 5 | import 'package:types/api_exception.dart'; 6 | import 'package:types/model/types200_response.dart'; 7 | 8 | /// Endpoints with tag Default 9 | class DefaultApi { 10 | DefaultApi(ApiClient? client) : client = client ?? ApiClient(); 11 | 12 | final ApiClient client; 13 | 14 | /// Get types 15 | Future types() async { 16 | final response = await client.invokeApi(method: Method.get, path: '/types'); 17 | 18 | if (response.statusCode >= HttpStatus.badRequest) { 19 | throw ApiException(response.statusCode, response.body); 20 | } 21 | 22 | if (response.body.isNotEmpty) { 23 | return Types200Response.fromJson( 24 | jsonDecode(response.body) as Map, 25 | ); 26 | } 27 | 28 | throw ApiException.unhandled(response.statusCode); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /gen_tests/tests/test/types_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:test/test.dart'; 2 | import 'package:types/api.dart'; 3 | 4 | void main() { 5 | group('Date', () { 6 | test('DateType', () async { 7 | final date = DateType('2021-01-01'); 8 | expect(date.toJson(), '2021-01-01'); 9 | expect(DateType.maybeFromJson(null), isNull); 10 | 11 | // TODO(eseidel): Date should validate the date string. 12 | expect(DateType.fromJson('not a date'), isA()); 13 | expect(DateType.fromJson('01-01-2021'), isA()); 14 | }); 15 | }); 16 | group('Email', () { 17 | test('EmailType', () async { 18 | final email = EmailType('test@example.com'); 19 | expect(email.toJson(), 'test@example.com'); 20 | expect(EmailType.maybeFromJson(null), isNull); 21 | 22 | // TODO(eseidel): Email should validate the email string. 23 | expect(EmailType.fromJson('not an email'), isA()); 24 | expect(EmailType.fromJson('test@example'), isA()); 25 | }); 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /lib/templates/analysis_options.mustache: -------------------------------------------------------------------------------- 1 | include: package:very_good_analysis/analysis_options.yaml 2 | analyzer: 3 | errors: 4 | # This triggers even from the same class, which is too much. 5 | # See also https://github.com/dart-lang/sdk/issues/61037 6 | deprecated_member_use_from_same_package: ignore 7 | linter: 8 | rules: 9 | # We do not generate docs yet. 10 | public_member_api_docs: false 11 | # OpenAPI has no deprecation messages. 12 | provide_deprecation_message: false 13 | # Some specs contain code blocks which are not properly annotated. 14 | # There isn't a great solution for us to fix them, so we ignore the lint. 15 | missing_code_block_language_in_doc_comment: false 16 | {{#screamingCapsEnums}} 17 | # When using SCREAMING_CAPS for enum values, we need to disable this rule. 18 | constant_identifier_names: false 19 | {{/screamingCapsEnums}} 20 | {{#mutableModels}} 21 | # When quirking to have mutable models, we need to disable this rule. 22 | avoid_equals_and_hash_code_on_mutable_classes: false 23 | {{/mutableModels}} -------------------------------------------------------------------------------- /test/types_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:space_gen/src/types.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | void main() { 5 | group('CommonProperties', () { 6 | test('copyWith', () { 7 | const common = CommonProperties.test( 8 | pointer: JsonPointer.fromParts(['foo']), 9 | snakeName: 'foo', 10 | title: 'Foo', 11 | description: 'Foo description', 12 | ); 13 | final copy = common.copyWith( 14 | pointer: const JsonPointer.fromParts(['bar']), 15 | snakeName: 'bar', 16 | title: 'Bar', 17 | description: 'Bar description', 18 | isDeprecated: true, 19 | nullable: true, 20 | ); 21 | expect(copy.pointer, const JsonPointer.fromParts(['bar'])); 22 | expect(copy.snakeName, 'bar'); 23 | expect(copy.title, 'Bar'); 24 | expect(copy.description, 'Bar description'); 25 | expect(copy.isDeprecated, true); 26 | expect(copy.nullable, true); 27 | final noChange = common.copyWith(); 28 | expect(noChange, equals(common)); 29 | }); 30 | }); 31 | } 32 | -------------------------------------------------------------------------------- /lib/templates/model_helpers.mustache: -------------------------------------------------------------------------------- 1 | import 'package:collection/collection.dart'; 2 | import 'package:uri/uri.dart'; 3 | 4 | /// Parse a nullable string as a DateTime. 5 | DateTime? maybeParseDateTime(String? value) { 6 | if (value == null) { 7 | return null; 8 | } 9 | return DateTime.parse(value); 10 | } 11 | 12 | /// Parse a nullable string as a Uri. 13 | Uri? maybeParseUri(String? value) { 14 | if (value == null) { 15 | return null; 16 | } 17 | return Uri.parse(value); 18 | } 19 | 20 | /// Parse a nullable string as a UriTemplate. 21 | UriTemplate? maybeParseUriTemplate(String? value) { 22 | if (value == null) { 23 | return null; 24 | } 25 | return UriTemplate(value); 26 | } 27 | 28 | /// Check if two nullable lists are deeply equal. 29 | bool listsEqual(List? a, List? b) { 30 | final deepEquals = const DeepCollectionEquality().equals; 31 | return deepEquals(a, b); 32 | } 33 | 34 | /// Check if two nullable maps are deeply equal. 35 | bool mapsEqual(Map? a, Map? b) { 36 | final deepEquals = const DeepCollectionEquality().equals; 37 | return deepEquals(a, b); 38 | } -------------------------------------------------------------------------------- /gen_tests/types/lib/model_helpers.dart: -------------------------------------------------------------------------------- 1 | import 'package:collection/collection.dart'; 2 | import 'package:uri/uri.dart'; 3 | 4 | /// Parse a nullable string as a DateTime. 5 | DateTime? maybeParseDateTime(String? value) { 6 | if (value == null) { 7 | return null; 8 | } 9 | return DateTime.parse(value); 10 | } 11 | 12 | /// Parse a nullable string as a Uri. 13 | Uri? maybeParseUri(String? value) { 14 | if (value == null) { 15 | return null; 16 | } 17 | return Uri.parse(value); 18 | } 19 | 20 | /// Parse a nullable string as a UriTemplate. 21 | UriTemplate? maybeParseUriTemplate(String? value) { 22 | if (value == null) { 23 | return null; 24 | } 25 | return UriTemplate(value); 26 | } 27 | 28 | /// Check if two nullable lists are deeply equal. 29 | bool listsEqual(List? a, List? b) { 30 | final deepEquals = const DeepCollectionEquality().equals; 31 | return deepEquals(a, b); 32 | } 33 | 34 | /// Check if two nullable maps are deeply equal. 35 | bool mapsEqual(Map? a, Map? b) { 36 | final deepEquals = const DeepCollectionEquality().equals; 37 | return deepEquals(a, b); 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Eric Seidel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/src/render/templates.dart: -------------------------------------------------------------------------------- 1 | import 'package:file/file.dart'; 2 | import 'package:file/local.dart'; 3 | import 'package:mustache_template/mustache_template.dart'; 4 | 5 | class TemplateProvider { 6 | TemplateProvider.fromDirectory(this.templateDir) { 7 | if (!templateDir.existsSync()) { 8 | throw Exception('Template directory does not exist: ${templateDir.path}'); 9 | } 10 | } 11 | 12 | TemplateProvider.defaultLocation() 13 | : templateDir = const LocalFileSystem().directory('lib/templates'); 14 | 15 | final Directory templateDir; 16 | // Reading the same template from disk repeatedly is slow, so cache them. 17 | // Saves about 4s on rendering the GitHub spec. 18 | final Map _cache = {}; 19 | 20 | Template loadTemplate(String name) { 21 | return _cache.putIfAbsent( 22 | name, 23 | () => Template( 24 | templateDir.childFile('$name.mustache').readAsStringSync(), 25 | partialResolver: loadTemplate, 26 | name: name, 27 | ), 28 | ); 29 | } 30 | 31 | String loadDartTemplate(String name) { 32 | return templateDir.childFile('$name.dart').readAsStringSync(); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /lib/templates/schema_enum.mustache: -------------------------------------------------------------------------------- 1 | {{{ doc_comment }}}enum {{ typeName }} { 2 | {{#enumValues}} 3 | {{ enumValueName }}._({{{ enumValue }}}), 4 | {{/enumValues}} 5 | ; 6 | 7 | const {{typeName}}._(this.value); 8 | 9 | /// Creates a {{ typeName }} from a json string. 10 | factory {{ typeName }}.fromJson(String json) { 11 | return {{ typeName }}.values.firstWhere( 12 | (value) => value.value == json, 13 | orElse: () => 14 | throw FormatException('Unknown {{ typeName }} value: $json') 15 | ); 16 | } 17 | 18 | /// Convenience to create a nullable type from a nullable json object. 19 | /// Useful when parsing optional fields. 20 | static {{ nullableTypeName }} maybeFromJson(String? json) { 21 | if (json == null) { 22 | return null; 23 | } 24 | return {{ typeName }}.fromJson(json); 25 | } 26 | 27 | /// The value of the enum, as a string. This is the exact value 28 | /// from the OpenAPI spec and will be used for network transport. 29 | final String value; 30 | 31 | /// Converts the enum to a json string. 32 | String toJson() => value; 33 | 34 | /// Returns the string value of the enum. 35 | @override 36 | String toString() => value; 37 | } 38 | -------------------------------------------------------------------------------- /gen_tests/types/lib/model/types200_response.dart: -------------------------------------------------------------------------------- 1 | import 'package:types/model/date_type.dart'; 2 | import 'package:types/model/email_type.dart'; 3 | 4 | class Types200Response { 5 | Types200Response({required this.date, required this.email}); 6 | 7 | factory Types200Response.fromJson(dynamic jsonArg) { 8 | final json = jsonArg as Map; 9 | return Types200Response( 10 | date: DateType.fromJson(json['date'] as String), 11 | email: EmailType.fromJson(json['email'] as String), 12 | ); 13 | } 14 | 15 | /// Convenience to create a nullable type from a nullable json object. 16 | /// Useful when parsing optional fields. 17 | static Types200Response? maybeFromJson(Map? json) { 18 | if (json == null) { 19 | return null; 20 | } 21 | return Types200Response.fromJson(json); 22 | } 23 | 24 | DateType date; 25 | EmailType email; 26 | 27 | Map toJson() { 28 | return {'date': date.toJson(), 'email': email.toJson()}; 29 | } 30 | 31 | @override 32 | int get hashCode => Object.hashAll([date, email]); 33 | 34 | @override 35 | bool operator ==(Object other) { 36 | if (identical(this, other)) return true; 37 | return other is Types200Response && 38 | date == other.date && 39 | email == other.email; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /.github/workflows/dart.yml: -------------------------------------------------------------------------------- 1 | name: Dart 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | branches: ["main"] 8 | 9 | jobs: 10 | cspell: 11 | name: 🔤 Check Spelling 12 | uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/spell_check.yml@v1 13 | with: 14 | config: cspell.config.yaml 15 | 16 | build: 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - uses: actions/checkout@v3 21 | 22 | # Note: This workflow uses the latest stable version of the Dart SDK. 23 | # You can specify other versions if desired, see documentation here: 24 | # https://github.com/dart-lang/setup-dart/blob/main/README.md 25 | - uses: dart-lang/setup-dart@v1 26 | 27 | - name: Install dependencies 28 | run: dart pub get 29 | 30 | - name: Verify formatting 31 | run: dart format --output=none --set-exit-if-changed . 32 | 33 | # Consider passing '--fatal-infos' for slightly stricter analysis. 34 | - name: Analyze project source 35 | run: dart analyze 36 | 37 | - name: Run tests with coverage 38 | run: ./coverage.sh 39 | 40 | - name: Upload coverage reports to Codecov 41 | uses: codecov/codecov-action@v5 42 | with: 43 | fail_ci_if_error: true 44 | token: ${{ secrets.CODECOV_TOKEN }} 45 | files: coverage/lcov.info 46 | -------------------------------------------------------------------------------- /GEMINI.md: -------------------------------------------------------------------------------- 1 | # Gemini Project Context 2 | 3 | This file provides context for the Gemini AI assistant to help it understand 4 | and work with this project more effectively. 5 | 6 | ## Project Overview 7 | 8 | `space_gen` is a code generation tool written in Dart. It takes an OpenAPI 3.0 9 | or 3.1 specification and generates a Dart client library. The goal is to 10 | produce high-quality, modern, and idiomatic Dart code that is as good as 11 | handwritten code. 12 | 13 | ## Key Technologies 14 | 15 | - **Language:** Dart 16 | - **Templating:** Mustache 17 | - **Dependency Management:** Pub 18 | - **Linting:** `very_good_analysis` 19 | 20 | ## Development Workflow 21 | 22 | ### Running the Generator 23 | 24 | To run the code generator, use the following command: 25 | 26 | ```bash 27 | dart run space_gen -i -o 28 | ``` 29 | 30 | ### Running Tests 31 | 32 | To run the project's test suite, use: 33 | 34 | ```bash 35 | dart test 36 | ``` 37 | 38 | ### Formatting and Analysis 39 | 40 | To format the code and check for static analysis issues, use the standard Dart 41 | commands: 42 | 43 | ```bash 44 | # Format code 45 | dart format . 46 | 47 | # Run static analysis 48 | dart analyze 49 | ``` 50 | 51 | ## Coding Conventions 52 | 53 | - **Commits:** Prefer adding new, atomic commits rather than amending existing 54 | ones when updating a pull request. 55 | - **Line Length:** All files should be wrapped to 80 columns. -------------------------------------------------------------------------------- /lib/src/quirks.dart: -------------------------------------------------------------------------------- 1 | /// Quirks are a set of flags that can be used to customize the generated code. 2 | class Quirks { 3 | const Quirks({ 4 | this.dynamicJson = false, 5 | this.mutableModels = false, 6 | // Avoiding ever having List? seems reasonable so we default to true. 7 | this.allListsDefaultToEmpty = true, 8 | this.nonNullableDefaultValues = false, 9 | this.screamingCapsEnums = false, 10 | }); 11 | 12 | const Quirks.openapi() 13 | : this( 14 | dynamicJson: true, 15 | mutableModels: true, 16 | nonNullableDefaultValues: true, 17 | allListsDefaultToEmpty: true, 18 | screamingCapsEnums: true, 19 | ); 20 | 21 | /// Use "dynamic" instead of "Map\" for passing to fromJson 22 | /// to match OpenAPI's behavior. 23 | final bool dynamicJson; 24 | 25 | /// Use mutable models instead of immutable ones to match OpenAPI's behavior. 26 | final bool mutableModels; 27 | 28 | /// OpenAPI seems to have the behavior whereby all Lists default to empty 29 | /// lists. 30 | final bool allListsDefaultToEmpty; 31 | 32 | /// OpenAPI seems to have the behavior whereby if a property has a default 33 | /// value it can never be nullable. Since OpenAPI also makes all Lists 34 | /// default to empty lists, this means that all Lists are non-nullable. 35 | final bool nonNullableDefaultValues; 36 | 37 | /// OpenAPI uses SCREAMING_CAPS for enum values, but that's not Dart style. 38 | final bool screamingCapsEnums; 39 | 40 | // Potential future quirks: 41 | 42 | /// OpenAPI flattens everything into the top level `lib` folder. 43 | // final bool doNotUseSrcPaths; 44 | } 45 | -------------------------------------------------------------------------------- /gen_tests/types.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.1.0", 3 | "info": { 4 | "title": "Types", 5 | "version": "1.0.0" 6 | }, 7 | "servers": [ 8 | { 9 | "url": "https://example.com/types" 10 | } 11 | ], 12 | "paths": { 13 | "/types": { 14 | "get": { 15 | "summary": "Get types", 16 | "responses": { 17 | "200": { 18 | "description": "OK", 19 | "content": { 20 | "application/json": { 21 | "schema": { 22 | "type": "object", 23 | "properties": { 24 | "date": { 25 | "$ref": "#/components/schemas/DateType" 26 | }, 27 | "email": { 28 | "$ref": "#/components/schemas/EmailType" 29 | } 30 | }, 31 | "required": ["date", "email"] 32 | } 33 | } 34 | } 35 | } 36 | } 37 | } 38 | } 39 | }, 40 | "components": { 41 | "schemas": { 42 | "DateType": { 43 | "type": "string", 44 | "format": "date" 45 | }, 46 | "EmailType": { 47 | "type": "string", 48 | "format": "email" 49 | } 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /bin/space_gen.dart: -------------------------------------------------------------------------------- 1 | import 'dart:isolate'; 2 | 3 | import 'package:args/args.dart'; 4 | import 'package:file/local.dart'; 5 | import 'package:path/path.dart' as p; 6 | import 'package:space_gen/space_gen.dart'; 7 | 8 | Future run(List arguments) async { 9 | const fs = LocalFileSystem(); 10 | final parser = ArgParser() 11 | ..addOption('in', abbr: 'i', help: 'Path or URL to spec', mandatory: true) 12 | ..addOption( 13 | 'out', 14 | abbr: 'o', 15 | help: 'Path to output directory', 16 | mandatory: true, 17 | ) 18 | ..addFlag('verbose', abbr: 'v', help: 'Verbose output') 19 | ..addFlag('openapi', help: 'Use OpenAPI quirks'); 20 | final results = parser.parse(arguments); 21 | if (results.rest.isNotEmpty) { 22 | logger 23 | ..err('Unexpected arguments: ${results.rest}') 24 | ..info(parser.usage); 25 | return 1; 26 | } 27 | 28 | final verbose = results['verbose'] as bool; 29 | if (verbose) { 30 | setVerboseLogging(); 31 | } 32 | 33 | final specUrl = Uri.parse(results['in'] as String); 34 | final outDir = fs.directory(results['out'] as String); 35 | final packageName = p.basename(outDir.path); 36 | final quirks = results['openapi'] as bool 37 | ? const Quirks.openapi() 38 | : const Quirks(); 39 | 40 | final templatesUri = await Isolate.resolvePackageUri( 41 | Uri.parse('package:space_gen/templates'), 42 | ); 43 | final templatesDir = templatesUri != null 44 | ? fs.directory(templatesUri.toFilePath()) 45 | // TODO(eseidel): This fallback is likely wrong. 46 | : fs.directory('lib/templates'); 47 | 48 | await loadAndRenderSpec( 49 | specUrl: specUrl, 50 | packageName: packageName, 51 | outDir: outDir, 52 | quirks: quirks, 53 | templatesDir: templatesDir, 54 | ); 55 | return 0; 56 | } 57 | 58 | Future main(List arguments) async { 59 | return runWithLogger(Logger(), () => run(arguments)); 60 | } 61 | -------------------------------------------------------------------------------- /test/parse/spec_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:space_gen/src/parse/spec.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | void main() { 5 | group('RefOr', () { 6 | test('equality', () { 7 | final bodyOne = RequestBody( 8 | pointer: JsonPointer.parse('#/components/requestBodies/Foo'), 9 | description: 'Foo', 10 | content: { 11 | 'application/json': MediaType( 12 | schema: SchemaRef.ref( 13 | '#/components/schemas/Foo', 14 | const JsonPointer.empty(), 15 | ), 16 | ), 17 | }, 18 | isRequired: true, 19 | ); 20 | final bodyTwo = RequestBody( 21 | pointer: JsonPointer.parse('#/components/requestBodies/Foo'), 22 | description: 'Foo', 23 | content: { 24 | 'application/json': MediaType( 25 | schema: SchemaRef.ref( 26 | '#/components/schemas/Foo', 27 | const JsonPointer.empty(), 28 | ), 29 | ), 30 | }, 31 | isRequired: true, 32 | ); 33 | final refOrOne = RefOr.object(bodyOne, const JsonPointer.empty()); 34 | final refOrTwo = RefOr.object(bodyTwo, const JsonPointer.empty()); 35 | final refOrThree = RefOr.object( 36 | RequestBody( 37 | pointer: JsonPointer.parse('#/components/requestBodies/Bar'), 38 | description: 'Bar', 39 | content: { 40 | 'application/json': MediaType( 41 | schema: SchemaRef.ref( 42 | '#/components/schemas/Bar', 43 | const JsonPointer.empty(), 44 | ), 45 | ), 46 | }, 47 | isRequired: true, 48 | ), 49 | const JsonPointer.empty(), 50 | ); 51 | expect(refOrOne, refOrTwo); 52 | expect(refOrOne, isNot(refOrThree)); 53 | expect(refOrOne.hashCode, refOrTwo.hashCode); 54 | expect(refOrOne.hashCode, isNot(refOrThree.hashCode)); 55 | }); 56 | }); 57 | } 58 | -------------------------------------------------------------------------------- /lib/src/loader.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:file/file.dart'; 4 | import 'package:http/http.dart' as http; 5 | import 'package:space_gen/src/logger.dart'; 6 | import 'package:yaml/yaml.dart' as yaml; 7 | 8 | typedef Json = Map; 9 | 10 | /// A cache of JSON objects. 11 | /// Handles both file and network urls. 12 | class Cache { 13 | Cache(this.fs, {http.Client? client}) : client = client ?? http.Client(); 14 | 15 | final FileSystem fs; 16 | final http.Client client; 17 | 18 | final _cache = {}; 19 | 20 | // Does not check the cache, does handle both file and network urls. 21 | Future _fetchWithoutCache(Uri uri) async { 22 | logger.detail('Loading $uri'); 23 | 24 | final isYaml = uri.path.endsWith('.yaml') || uri.path.endsWith('.yml'); 25 | 26 | Json decode(String content) { 27 | if (isYaml) { 28 | final yamlDoc = yaml.loadYaml(content); 29 | // re-encode as json to get a valid json object. 30 | final jsonString = jsonEncode(yamlDoc); 31 | return jsonDecode(jsonString) as Json; 32 | } 33 | return jsonDecode(content) as Json; 34 | } 35 | 36 | if (!uri.hasScheme || uri.scheme == 'file') { 37 | final content = fs.file(uri.toFilePath()).readAsStringSync(); 38 | return decode(content); 39 | } 40 | 41 | final response = await client.get(uri); 42 | return decode(response.body); 43 | } 44 | 45 | Future load(Uri uri) async { 46 | if (uri.fragment.isNotEmpty) { 47 | throw Exception('Fragment not supported: $uri'); 48 | } 49 | 50 | // Check the cache first. 51 | final maybeJson = _cache[uri]; 52 | if (maybeJson != null) { 53 | return maybeJson; 54 | } 55 | 56 | final json = await _fetchWithoutCache(uri); 57 | _cache[uri] = json; 58 | return json; 59 | } 60 | 61 | Json? get(Uri uri) { 62 | if (uri.fragment.isNotEmpty) { 63 | throw Exception('Fragment not supported: $uri'); 64 | } 65 | return _cache[uri]; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /test/string_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:space_gen/src/string.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | void main() { 5 | test('snakeFromCamel', () { 6 | expect(snakeFromCamel('snakeFromCamel'), 'snake_from_camel'); 7 | expect(snakeFromCamel('SnakeFromCamel'), 'snake_from_camel'); 8 | expect(snakeFromCamel('snake_from_camel'), 'snake_from_camel'); 9 | expect(snakeFromCamel('Snake_from_camel'), 'snake_from_camel'); 10 | expect(snakeFromCamel('snakeFromCamel'), 'snake_from_camel'); 11 | }); 12 | 13 | test('toSnakeCase', () { 14 | expect(toSnakeCase('snakeFromCamel'), 'snake_from_camel'); 15 | expect(toSnakeCase('SnakeFromCamel'), 'snake_from_camel'); 16 | expect(toSnakeCase('snake_from_camel'), 'snake_from_camel'); 17 | expect(toSnakeCase('Snake_from_camel'), 'snake_from_camel'); 18 | expect(toSnakeCase('snakeFromCamel'), 'snake_from_camel'); 19 | expect(toSnakeCase('snake-from-camel'), 'snake_from_camel'); 20 | }); 21 | 22 | test('camelFromSnake', () { 23 | expect(camelFromSnake('camel_from_snake'), 'CamelFromSnake'); 24 | expect(camelFromSnake('Camel_from_snake'), 'CamelFromSnake'); 25 | expect(camelFromSnake('camel_from_snake'), 'CamelFromSnake'); 26 | expect(camelFromSnake('Camel_from_snake'), 'CamelFromSnake'); 27 | }); 28 | 29 | test('toLowerCamelCase', () { 30 | expect(toLowerCamelCase('CAMEL_FROM_CAPS'), 'camelFromCaps'); 31 | expect(toLowerCamelCase('camel_from_caps'), 'camelFromCaps'); 32 | expect(toLowerCamelCase('camelFromCaps'), 'camelFromCaps'); 33 | expect(toLowerCamelCase('camel-from-caps'), 'camelFromCaps'); 34 | }); 35 | 36 | test('isReservedWord', () { 37 | expect(isReservedWord('void'), true); 38 | expect(isReservedWord('int'), true); 39 | expect(isReservedWord('double'), true); 40 | expect(isReservedWord('num'), true); 41 | expect(isReservedWord('bool'), true); 42 | expect(isReservedWord('dynamic'), true); 43 | expect(isReservedWord('String'), false); 44 | expect(isReservedWord('string'), false); 45 | expect(isReservedWord('String'), false); 46 | expect(isReservedWord('string'), false); 47 | expect(isReservedWord('in'), true); 48 | }); 49 | } 50 | -------------------------------------------------------------------------------- /lib/templates/api.mustache: -------------------------------------------------------------------------------- 1 | {{{ api_doc_comment }}}class {{ className }} { 2 | {{ className }}(ApiClient? client) : client = client ?? ApiClient(); 3 | 4 | final ApiClient client; 5 | 6 | {{#endpoints}} 7 | {{{ endpoint_doc_comment }}}Future<{{{ returnType }}}> {{{ methodName }}}( 8 | {{#positionalParameters}} 9 | {{{ type }}} {{{ dartName }}}, 10 | {{/positionalParameters}} 11 | {{#hasNamedParameters}} 12 | { {{#namedParameters}}{{#required}}required {{{ type }}}{{/required}}{{^required}}{{{ nullableType }}}{{/required}} {{{ dartName }}}{{#hasDefaultValue}} = {{{ defaultValue }}}{{/hasDefaultValue}},{{/namedParameters}} } 13 | {{/hasNamedParameters}} 14 | ) async { 15 | {{{ validationStatements }}}final response = await client.invokeApi( 16 | method: Method.{{{httpMethod}}}, 17 | path: '{{{path}}}' 18 | {{#pathParameters}} 19 | .replaceAll('{{{ bracketedName }}}', "${ {{{ toJson }}} }") 20 | {{/pathParameters}}, 21 | {{#hasQueryParameters}} 22 | queryParameters: { 23 | {{#queryParameters}} 24 | '{{{ name }}}': {{#isNullable}}?{{/isNullable}}{{{ toJson }}}.toString(), 25 | {{/queryParameters}} 26 | }, 27 | {{/hasQueryParameters}} 28 | {{#requestBody}} 29 | body: {{{ encodedBody }}}, 30 | {{/requestBody}} 31 | {{#hasHeaderParameters}} 32 | headerParameters: { 33 | {{#headerParameters}} 34 | '{{{ name }}}': {{#isNullable}}?{{/isNullable}}{{{ toJson }}}, 35 | {{/headerParameters}} 36 | }, 37 | {{/hasHeaderParameters}} 38 | {{#authArgument}} 39 | authRequest: {{{ authArgument }}}, 40 | {{/authArgument}} 41 | ); 42 | 43 | if (response.statusCode >= HttpStatus.badRequest) { 44 | throw ApiException(response.statusCode, response.body.toString()); 45 | } 46 | 47 | if (response.body.isNotEmpty) { 48 | return {{{ responseFromJson }}}; 49 | } 50 | 51 | throw ApiException.unhandled(response.statusCode); 52 | } 53 | {{/endpoints}} 54 | } 55 | -------------------------------------------------------------------------------- /lib/src/render/schema_renderer.dart: -------------------------------------------------------------------------------- 1 | import 'package:space_gen/src/quirks.dart'; 2 | import 'package:space_gen/src/render/render_tree.dart'; 3 | import 'package:space_gen/src/render/templates.dart'; 4 | 5 | /// Context for rendering the spec. 6 | class SchemaRenderer { 7 | /// Create a new context for rendering the spec. 8 | SchemaRenderer({required this.templates, this.quirks = const Quirks()}); 9 | 10 | /// The provider of templates. 11 | final TemplateProvider templates; 12 | 13 | /// The quirks to use for rendering. 14 | final Quirks quirks; 15 | 16 | /// The type of the json object passed to fromJson. 17 | String get fromJsonJsonType => 18 | quirks.dynamicJson ? 'dynamic' : 'Map'; 19 | 20 | /// Renders a schema to a string, does not render the imports. 21 | String renderSchema(RenderSchema schema) { 22 | if (!schema.createsNewType) { 23 | throw StateError('No code to render non-newtype: $schema'); 24 | } 25 | final template = switch (schema) { 26 | RenderEnum() => 'schema_enum', 27 | RenderObject() => 'schema_object', 28 | RenderString() => 'schema_string_newtype', 29 | RenderInteger() || RenderNumber() => 'schema_number_newtype', 30 | RenderOneOf() => 'schema_one_of', 31 | RenderEmptyObject() => 'schema_empty_object', 32 | RenderSchema() => throw StateError('No code to render $schema'), 33 | }; 34 | return templates 35 | .loadTemplate(template) 36 | .renderString(schema.toTemplateContext(this)); 37 | } 38 | 39 | String renderEndpoints({ 40 | required String? description, 41 | required String className, 42 | required List endpoints, 43 | String? removePrefix, 44 | }) { 45 | final endpointsContext = endpoints 46 | .map((e) => e.toTemplateContext(this, removePrefix: removePrefix)) 47 | .toList(); 48 | return templates.loadTemplate('api').renderString({ 49 | 'api_doc_comment': createDocCommentFromParts(body: description), 50 | 'className': className, 51 | 'endpoints': endpointsContext, 52 | }); 53 | } 54 | 55 | /// Renders an api to a string, does not render the imports. 56 | String renderApi(Api api) => renderEndpoints( 57 | description: api.description, 58 | className: api.className, 59 | endpoints: api.endpoints, 60 | removePrefix: api.removePrefix, 61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /lib/src/render/tree_visitor.dart: -------------------------------------------------------------------------------- 1 | import 'package:space_gen/src/render/render_tree.dart'; 2 | 3 | class RenderTreeVisitor { 4 | void visitSchema(RenderSchema schema) {} 5 | void visitApi(Api api) {} 6 | void visitEndpoint(Endpoint endpoint) {} 7 | void visitOperation(RenderOperation operation) {} 8 | void visitParameter(RenderParameter parameter) {} 9 | void visitRequestBody(RenderRequestBody requestBody) {} 10 | void visitResponse(RenderResponse response) {} 11 | } 12 | 13 | class RenderTreeWalker { 14 | RenderTreeWalker({required this.visitor}); 15 | final RenderTreeVisitor visitor; 16 | 17 | void walkRoot(RenderSpec spec) { 18 | for (final api in spec.apis) { 19 | walkApi(api); 20 | } 21 | } 22 | 23 | void maybeWalkSchema(RenderSchema? schema) { 24 | if (schema != null) { 25 | walkSchema(schema); 26 | } 27 | } 28 | 29 | void walkSchema(RenderSchema schema) { 30 | visitor.visitSchema(schema); 31 | switch (schema) { 32 | case RenderObject(): 33 | for (final property in schema.properties.values) { 34 | walkSchema(property); 35 | } 36 | maybeWalkSchema(schema.additionalProperties); 37 | case RenderArray(): 38 | maybeWalkSchema(schema.items); 39 | case RenderOneOf(): 40 | for (final schema in schema.schemas) { 41 | walkSchema(schema); 42 | } 43 | case RenderMap(): 44 | walkSchema(schema.valueSchema); 45 | case RenderEnum(): 46 | case RenderString(): 47 | case RenderInteger(): 48 | case RenderNumber(): 49 | case RenderPod(): 50 | case RenderUnknown(): 51 | case RenderVoid(): 52 | break; 53 | } 54 | } 55 | 56 | void walkApi(Api api) { 57 | for (final endpoint in api.endpoints) { 58 | walkEndpoint(endpoint); 59 | } 60 | } 61 | 62 | void walkEndpoint(Endpoint endpoint) { 63 | visitor.visitEndpoint(endpoint); 64 | walkOperation(endpoint.operation); 65 | } 66 | 67 | void walkParameter(RenderParameter parameter) { 68 | visitor.visitParameter(parameter); 69 | walkSchema(parameter.type); 70 | } 71 | 72 | void walkOperation(RenderOperation operation) { 73 | visitor.visitOperation(operation); 74 | final requestBody = operation.requestBody; 75 | if (requestBody != null) { 76 | walkRequestBody(requestBody); 77 | } 78 | for (final response in operation.responses) { 79 | walkResponse(response); 80 | } 81 | for (final parameter in operation.parameters) { 82 | walkParameter(parameter); 83 | } 84 | walkSchema(operation.returnType); 85 | } 86 | 87 | void walkRequestBody(RenderRequestBody requestBody) { 88 | visitor.visitRequestBody(requestBody); 89 | walkSchema(requestBody.schema); 90 | } 91 | 92 | void walkResponse(RenderResponse response) { 93 | visitor.visitResponse(response); 94 | walkSchema(response.content); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /lib/templates/schema_object.mustache: -------------------------------------------------------------------------------- 1 | {{{ doc_comment }}}{{^mutableModels}}@immutable{{/mutableModels}} 2 | class {{ typeName }} { 3 | {{ typeName }}( 4 | {{#hasProperties}} 5 | { {{#properties}}{{{ argumentLine }}}, {{/properties}} 6 | {{#hasAdditionalProperties}}required this.{{additionalPropertiesName}},{{/hasAdditionalProperties}} } 7 | {{/hasProperties}} 8 | ){{{assignmentsLine}}}; 9 | 10 | factory {{ typeName }}.fromJson({{{fromJsonJsonType}}} 11 | {{#castFromJsonArg}} 12 | jsonArg) { 13 | final json = jsonArg as Map; 14 | {{/castFromJsonArg}} 15 | {{^castFromJsonArg}} 16 | json) { 17 | {{/castFromJsonArg}} 18 | return {{ typeName }}( 19 | {{#properties}} 20 | {{ dartName }}: {{{ fromJson }}}, 21 | {{/properties}} 22 | {{#hasAdditionalProperties}} 23 | {{additionalPropertiesName}}: json.map((key, value) => MapEntry(key, {{{ valueFromJson }}})), 24 | {{/hasAdditionalProperties}} 25 | ); 26 | } 27 | 28 | /// Convenience to create a nullable type from a nullable json object. 29 | /// Useful when parsing optional fields. 30 | static {{ nullableTypeName }} maybeFromJson(Map? json) { 31 | if (json == null) { 32 | return null; 33 | } 34 | return {{ typeName }}.fromJson(json); 35 | } 36 | 37 | {{#properties}}{{{ storageTypeDeclaration }}}{{ dartName }}; 38 | {{/properties}} 39 | {{#hasAdditionalProperties}} 40 | final Map {{additionalPropertiesName}}; 41 | 42 | {{{ valueSchema }}}? operator [](String key) => {{additionalPropertiesName}}[key]; 43 | {{/hasAdditionalProperties}} 44 | 45 | Map toJson() { 46 | return { 47 | {{#properties}} 48 | {{{ jsonName }}}: {{{ toJson }}}, 49 | {{/properties}} 50 | {{#hasAdditionalProperties}} 51 | ...{{additionalPropertiesName}}.map((key, value) => MapEntry(key, {{{ valueToJson }}})), 52 | {{/hasAdditionalProperties}} 53 | }; 54 | } 55 | 56 | @override 57 | int get hashCode => 58 | {{#hasOneProperty}} 59 | {{#properties}} 60 | {{ dartName }}.hashCode; 61 | {{/properties}} 62 | {{#hasAdditionalProperties}} 63 | {{additionalPropertiesName}}.hashCode; 64 | {{/hasAdditionalProperties}} 65 | {{/hasOneProperty}} 66 | {{^hasOneProperty}} 67 | Object.hashAll([ 68 | {{#properties}} 69 | {{ dartName }}, 70 | {{/properties}} 71 | {{#hasAdditionalProperties}} 72 | {{additionalPropertiesName}}, 73 | {{/hasAdditionalProperties}} 74 | ]); 75 | {{/hasOneProperty}} 76 | 77 | @override 78 | bool operator ==(Object other) { 79 | if (identical(this, other)) return true; 80 | return other is {{ typeName }} 81 | {{#properties}} 82 | && {{ equals}} 83 | {{/properties}} 84 | {{#hasAdditionalProperties}} 85 | && mapsEqual({{additionalPropertiesName}}, other.{{additionalPropertiesName}}) 86 | {{/hasAdditionalProperties}} 87 | ; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /lib/src/string.dart: -------------------------------------------------------------------------------- 1 | // Convert CamelCase to snake_case 2 | String snakeFromCamel(String camel) { 3 | final snake = camel.splitMapJoin( 4 | RegExp('[A-Z]'), 5 | onMatch: (m) => '_${m.group(0)}'.toLowerCase(), 6 | onNonMatch: (n) => n.toLowerCase(), 7 | ); 8 | return snake.startsWith('_') ? snake.substring(1) : snake; 9 | } 10 | 11 | /// Convert snake_case to CamelCase. 12 | String camelFromSnake(String snake) { 13 | return snake.splitMapJoin( 14 | RegExp('_'), 15 | onMatch: (m) => '', 16 | onNonMatch: (n) => n.capitalizeFirst(), 17 | ); 18 | } 19 | 20 | String lowercaseCamelFromSnake(String snake) => 21 | camelFromSnake(snake).lowerFirst(); 22 | 23 | String toSnakeCase(String unknown) { 24 | // We don't know the casing the author used. 25 | // First try to convert any UpperCase to snake_case. 26 | final lowered = snakeFromCamel(unknown); 27 | // Then convert any kebab-case to snake_case. 28 | return snakeFromKebab(lowered); 29 | } 30 | 31 | /// Convert kebab-case to snake_case. 32 | String snakeFromKebab(String kebab) => kebab.replaceAll('-', '_'); 33 | 34 | /// Converts from SCREAMING_CAPS, snake_case or kebab-case to camelCase. 35 | String toLowerCamelCase(String caps) { 36 | // Our SCREAMING_CAPS logic is not safe for camelCase input, 37 | // so we check for it first. 38 | final isScreamingCaps = RegExp(r'^[A-Z0-9_]+$').hasMatch(caps); 39 | if (!isScreamingCaps) { 40 | final snake = snakeFromKebab(caps); 41 | // Still convert snake_case to camelCase. 42 | if (snake.contains('_')) { 43 | return lowercaseCamelFromSnake(snake); 44 | } 45 | return caps.lowerFirst(); 46 | } 47 | // SCREAMING_CAPS -> lowerCamelCase 48 | final camel = caps.splitMapJoin( 49 | RegExp('_'), 50 | onMatch: (m) => '', 51 | onNonMatch: (n) => n.toLowerCase().capitalizeFirst(), 52 | ); 53 | return camel.lowerFirst(); 54 | } 55 | 56 | /// Converts from SCREAMING_CAPS, snake_case or kebab-case to CamelCase. 57 | String toUpperCamelCase(String snake) => 58 | toLowerCamelCase(snake).capitalizeFirst(); 59 | 60 | bool isReservedWord(String word) { 61 | // Eventually we should add them all: 62 | // https://dart.dev/language/keywords 63 | const reservedWords = { 64 | 'bool', 65 | 'default', 66 | 'double', 67 | 'dynamic', 68 | 'false', 69 | 'in', 70 | 'int', 71 | 'new', 72 | 'null', 73 | 'num', 74 | 'required', 75 | 'true', 76 | 'void', 77 | 'yield', 78 | }; 79 | return reservedWords.contains(word); 80 | } 81 | 82 | String quoteString(String string) { 83 | return "'${string.replaceAll("'", r"\'")}'"; 84 | } 85 | 86 | extension CapitalizeString on String { 87 | String capitalizeFirst() { 88 | if (isEmpty) { 89 | return this; 90 | } 91 | return '${this[0].toUpperCase()}${substring(1)}'; 92 | } 93 | 94 | String lowerFirst() { 95 | if (isEmpty) { 96 | return this; 97 | } 98 | return '${this[0].toLowerCase()}${substring(1)}'; 99 | } 100 | } 101 | 102 | /// This is a special case which just tries to use the first segment of a 103 | /// snake_case name as the prefix. 104 | String sharedPrefixFromSnakeNames(List values) { 105 | final prefix = '${values.first.split('_').first}_'; 106 | for (final value in values) { 107 | if (!value.startsWith(prefix)) { 108 | return ''; 109 | } 110 | } 111 | return prefix; 112 | } 113 | -------------------------------------------------------------------------------- /lib/templates/api_exception.dart: -------------------------------------------------------------------------------- 1 | import 'package:meta/meta.dart'; 2 | 3 | /// An exception thrown by the API client. 4 | /// 5 | /// This is a wrapper around the underlying network exceptions. 6 | /// This is used to provide a standard exception type for clients to handle. 7 | @immutable 8 | class ApiException implements Exception { 9 | const ApiException(this.code, this.message) 10 | : innerException = null, 11 | stackTrace = null; 12 | 13 | const ApiException.unhandled(this.code) 14 | : message = 'Unhandled response', 15 | innerException = null, 16 | stackTrace = null; 17 | 18 | const ApiException.withInner( 19 | this.code, 20 | this.message, 21 | this.innerException, 22 | this.stackTrace, 23 | ); 24 | 25 | final int code; 26 | final String? message; 27 | final Exception? innerException; 28 | final StackTrace? stackTrace; 29 | 30 | @override 31 | String toString() { 32 | if (message == null) { 33 | return 'ApiException'; 34 | } 35 | if (innerException == null) { 36 | return 'ApiException $code: $message'; 37 | } 38 | return 'ApiException $code: $message ' 39 | '(Inner exception: $innerException)\n\n$stackTrace'; 40 | } 41 | } 42 | 43 | /// Validates a number. 44 | /// 45 | /// These are extensions on the num type that throw an exception if the value 46 | /// does not meet the validation criteria. 47 | extension ValidateNumber on num { 48 | void validateMinimum(num minimum) { 49 | if (this < minimum) { 50 | throw Exception('must be greater than or equal to $minimum'); 51 | } 52 | } 53 | 54 | void validateMaximum(num maximum) { 55 | if (this > maximum) { 56 | throw Exception('must be less than or equal to $maximum'); 57 | } 58 | } 59 | 60 | void validateExclusiveMinimum(num minimum) { 61 | if (this <= minimum) { 62 | throw Exception('must be greater than $minimum'); 63 | } 64 | } 65 | 66 | void validateExclusiveMaximum(num maximum) { 67 | if (this >= maximum) { 68 | throw Exception('must be less than $maximum'); 69 | } 70 | } 71 | 72 | void validateMultipleOf(num multiple) { 73 | if (this % multiple != 0) { 74 | throw Exception('must be a multiple of $multiple'); 75 | } 76 | } 77 | } 78 | 79 | /// Validates a string. 80 | /// 81 | /// These are extensions on the String type that throw an exception if the value 82 | /// does not meet the validation criteria. 83 | extension ValidateString on String { 84 | void validateMinimumLength(int minimum) { 85 | if (length < minimum) { 86 | throw Exception('must be at least $minimum characters long'); 87 | } 88 | } 89 | 90 | void validateMaximumLength(int maximum) { 91 | if (length > maximum) { 92 | throw Exception('must be at most $maximum characters long'); 93 | } 94 | } 95 | 96 | void validatePattern(String pattern) { 97 | if (!RegExp(pattern).hasMatch(this)) { 98 | throw Exception('must match the pattern $pattern'); 99 | } 100 | } 101 | } 102 | 103 | extension ValidateArray on List { 104 | void validateMaximumItems(int maximum) { 105 | if (length > maximum) { 106 | throw Exception('must be at most $maximum items long'); 107 | } 108 | } 109 | 110 | void validateMinimumItems(int minimum) { 111 | if (length < minimum) { 112 | throw Exception('must be at least $minimum items long'); 113 | } 114 | } 115 | 116 | void validateUniqueItems() { 117 | if (toSet().length != length) { 118 | throw Exception('must contain unique items'); 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /gen_tests/types/lib/api_exception.dart: -------------------------------------------------------------------------------- 1 | import 'package:meta/meta.dart'; 2 | 3 | /// An exception thrown by the API client. 4 | /// 5 | /// This is a wrapper around the underlying network exceptions. 6 | /// This is used to provide a standard exception type for clients to handle. 7 | @immutable 8 | class ApiException implements Exception { 9 | const ApiException(this.code, this.message) 10 | : innerException = null, 11 | stackTrace = null; 12 | 13 | const ApiException.unhandled(this.code) 14 | : message = 'Unhandled response', 15 | innerException = null, 16 | stackTrace = null; 17 | 18 | const ApiException.withInner( 19 | this.code, 20 | this.message, 21 | this.innerException, 22 | this.stackTrace, 23 | ); 24 | 25 | final int code; 26 | final String? message; 27 | final Exception? innerException; 28 | final StackTrace? stackTrace; 29 | 30 | @override 31 | String toString() { 32 | if (message == null) { 33 | return 'ApiException'; 34 | } 35 | if (innerException == null) { 36 | return 'ApiException $code: $message'; 37 | } 38 | return 'ApiException $code: $message ' 39 | '(Inner exception: $innerException)\n\n$stackTrace'; 40 | } 41 | } 42 | 43 | /// Validates a number. 44 | /// 45 | /// These are extensions on the num type that throw an exception if the value 46 | /// does not meet the validation criteria. 47 | extension ValidateNumber on num { 48 | void validateMinimum(num minimum) { 49 | if (this < minimum) { 50 | throw Exception('must be greater than or equal to $minimum'); 51 | } 52 | } 53 | 54 | void validateMaximum(num maximum) { 55 | if (this > maximum) { 56 | throw Exception('must be less than or equal to $maximum'); 57 | } 58 | } 59 | 60 | void validateExclusiveMinimum(num minimum) { 61 | if (this <= minimum) { 62 | throw Exception('must be greater than $minimum'); 63 | } 64 | } 65 | 66 | void validateExclusiveMaximum(num maximum) { 67 | if (this >= maximum) { 68 | throw Exception('must be less than $maximum'); 69 | } 70 | } 71 | 72 | void validateMultipleOf(num multiple) { 73 | if (this % multiple != 0) { 74 | throw Exception('must be a multiple of $multiple'); 75 | } 76 | } 77 | } 78 | 79 | /// Validates a string. 80 | /// 81 | /// These are extensions on the String type that throw an exception if the value 82 | /// does not meet the validation criteria. 83 | extension ValidateString on String { 84 | void validateMinimumLength(int minimum) { 85 | if (length < minimum) { 86 | throw Exception('must be at least $minimum characters long'); 87 | } 88 | } 89 | 90 | void validateMaximumLength(int maximum) { 91 | if (length > maximum) { 92 | throw Exception('must be at most $maximum characters long'); 93 | } 94 | } 95 | 96 | void validatePattern(String pattern) { 97 | if (!RegExp(pattern).hasMatch(this)) { 98 | throw Exception('must match the pattern $pattern'); 99 | } 100 | } 101 | } 102 | 103 | extension ValidateArray on List { 104 | void validateMaximumItems(int maximum) { 105 | if (length > maximum) { 106 | throw Exception('must be at most $maximum items long'); 107 | } 108 | } 109 | 110 | void validateMinimumItems(int minimum) { 111 | if (length < minimum) { 112 | throw Exception('must be at least $minimum items long'); 113 | } 114 | } 115 | 116 | void validateUniqueItems() { 117 | if (toSet().length != length) { 118 | throw Exception('must contain unique items'); 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /test/parse/visitor_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:space_gen/src/parse/spec.dart'; 2 | import 'package:space_gen/src/parse/visitor.dart'; 3 | import 'package:space_gen/src/parser.dart'; 4 | import 'package:test/test.dart'; 5 | 6 | class _CountingVisitor extends Visitor { 7 | _CountingVisitor(); 8 | final Map _counts = {}; 9 | 10 | void count(String name) { 11 | _counts[name] = (_counts[name] ?? 0) + 1; 12 | } 13 | 14 | @override 15 | void visitRoot(OpenApi root) => count('root'); 16 | 17 | @override 18 | void visitSchema(Schema schema) => count('schema'); 19 | 20 | @override 21 | void visitParameter(Parameter parameter) => count('parameter'); 22 | 23 | @override 24 | void visitRequestBody(RequestBody requestBody) => count('requestBody'); 25 | 26 | @override 27 | void visitResponse(Response response) => count('response'); 28 | 29 | @override 30 | void visitHeader(Header header) => count('header'); 31 | 32 | @override 33 | void visitPathItem(PathItem pathItem) => count('pathItem'); 34 | 35 | @override 36 | void visitOperation(Operation operation) => count('operation'); 37 | 38 | @override 39 | void visitRefOr(RefOr refOr) => count('refOr'); 40 | } 41 | 42 | void main() { 43 | test('SpecWalker smoke test', () { 44 | final spec = { 45 | 'openapi': '3.0.0', 46 | 'info': {'title': 'Test', 'version': '1.0.0'}, 47 | 'servers': [ 48 | {'url': 'https://example.com'}, 49 | ], 50 | 'paths': { 51 | '/test': { 52 | 'get': { 53 | 'responses': { 54 | '200': {'description': 'OK'}, 55 | }, 56 | }, 57 | }, 58 | }, 59 | 'components': { 60 | 'schemas': { 61 | 'Test': { 62 | 'type': 'object', 63 | 'properties': { 64 | 'foo': {'type': 'string'}, 65 | }, 66 | }, 67 | }, 68 | 'parameters': { 69 | 'Test': { 70 | 'name': 'Test', 71 | 'in': 'query', 72 | 'required': true, 73 | 'schema': { 74 | 'type': 'object', 75 | 'properties': { 76 | 'foo': {'type': 'string'}, 77 | }, 78 | }, 79 | }, 80 | }, 81 | 'requestBodies': { 82 | 'Test': { 83 | 'content': { 84 | 'application/json': { 85 | 'schema': { 86 | 'type': 'object', 87 | 'properties': { 88 | 'foo': {'type': 'string'}, 89 | }, 90 | }, 91 | }, 92 | }, 93 | }, 94 | }, 95 | 'responses': { 96 | '200': { 97 | 'description': 'OK', 98 | 'content': { 99 | 'application/json': { 100 | 'schema': { 101 | 'type': 'object', 102 | 'properties': { 103 | 'foo': {'type': 'string'}, 104 | 'binary': {'type': 'string', 'format': 'binary'}, 105 | }, 106 | }, 107 | }, 108 | }, 109 | }, 110 | }, 111 | 'headers': { 112 | 'Test': { 113 | 'description': 'Test', 114 | 'schema': {'type': 'string'}, 115 | }, 116 | }, 117 | }, 118 | }; 119 | final parsed = parseOpenApi(spec); 120 | final visitor = _CountingVisitor(); 121 | SpecWalker(visitor).walkRoot(parsed); 122 | expect(visitor._counts, { 123 | 'root': 1, 124 | 'pathItem': 1, 125 | 'operation': 1, 126 | 'refOr': 15, 127 | 'response': 2, 128 | 'schema': 10, 129 | 'parameter': 1, 130 | 'requestBody': 1, 131 | 'header': 1, 132 | }); 133 | }); 134 | } 135 | -------------------------------------------------------------------------------- /tool/gen_tests.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io' as io; 2 | 3 | import 'package:args/args.dart'; 4 | import 'package:file/file.dart'; 5 | import 'package:file/local.dart'; 6 | import 'package:path/path.dart' as p; 7 | import 'package:space_gen/space_gen.dart'; 8 | 9 | class TestCase { 10 | TestCase({ 11 | required this.spec, 12 | required this.outDir, 13 | }); 14 | final File spec; 15 | final Directory outDir; 16 | 17 | String get packageName => outDir.basename; 18 | } 19 | 20 | List collectTests({ 21 | required List testDirs, 22 | List globList = const [], 23 | List skipList = const [], 24 | }) { 25 | bool shouldInclude({required File specFile, required String packageName}) { 26 | if (skipList.contains(packageName)) { 27 | logger.info('Skipping $packageName'); 28 | return false; 29 | } 30 | // Could use a fancier glob matcher, right now using contains. 31 | if (globList.isNotEmpty) { 32 | if (!globList.any((glob) => specFile.path.contains(glob))) { 33 | logger.info( 34 | 'Skipping $packageName because it does not match $globList', 35 | ); 36 | return false; 37 | } 38 | } 39 | return true; 40 | } 41 | 42 | final tests = []; 43 | for (final testDir in testDirs) { 44 | final specFiles = testDir 45 | .listSync() 46 | .whereType() 47 | .where((file) => file.path.endsWith('.json')) 48 | .toList(); 49 | for (final specFile in specFiles) { 50 | final specName = p.basenameWithoutExtension(specFile.path); 51 | final packageName = specName.replaceAll('.', '_').replaceAll('-', '_'); 52 | if (!shouldInclude(specFile: specFile, packageName: packageName)) { 53 | continue; 54 | } 55 | final outDir = testDir.childDirectory(packageName); 56 | tests.add(TestCase(spec: specFile, outDir: outDir)); 57 | } 58 | } 59 | return tests; 60 | } 61 | 62 | Future runTest({ 63 | required TestCase testCase, 64 | required Directory templatesDir, 65 | required Quirks quirks, 66 | }) async { 67 | final specFile = testCase.spec; 68 | final outDir = testCase.outDir; 69 | await loadAndRenderSpec( 70 | specUrl: specFile.uri, 71 | outDir: outDir, 72 | packageName: testCase.packageName, 73 | quirks: quirks, 74 | templatesDir: templatesDir, 75 | ); 76 | } 77 | 78 | Future run({ 79 | bool verbose = false, 80 | List skipList = const [], 81 | List globList = const [], 82 | }) async { 83 | if (verbose) { 84 | setVerboseLogging(); 85 | } 86 | const fs = LocalFileSystem(); 87 | final packageRoot = fs.currentDirectory; 88 | final potentialTestDirs = [ 89 | packageRoot.childDirectory('gen_tests'), 90 | packageRoot.childDirectory('../gen_tests'), 91 | // packageRoot.childDirectory('../private_gen_tests'), 92 | ]; 93 | final testDirs = potentialTestDirs.where((dir) => dir.existsSync()).toList(); 94 | final tests = collectTests( 95 | testDirs: testDirs, 96 | globList: globList, 97 | skipList: skipList, 98 | ); 99 | final templatesDir = fs.directory('lib/templates'); 100 | const quirks = Quirks.openapi(); 101 | for (final test in tests) { 102 | await runTest( 103 | testCase: test, 104 | templatesDir: templatesDir, 105 | quirks: quirks, 106 | ); 107 | } 108 | 109 | // Run the unit tests. 110 | final result = await io.Process.run( 111 | 'dart', 112 | ['test', '.'], 113 | workingDirectory: packageRoot 114 | .childDirectory('gen_tests') 115 | .childDirectory('tests') 116 | .path, 117 | ); 118 | logger 119 | ..info(result.stdout as String) 120 | ..info(result.stderr as String); 121 | if (result.exitCode != 0) { 122 | throw Exception('Unit tests failed'); 123 | } 124 | } 125 | 126 | void main(List args) async { 127 | final parser = ArgParser() 128 | ..addFlag('verbose', abbr: 'v', help: 'Verbose output') 129 | ..addMultiOption( 130 | 'ignore', 131 | abbr: 'i', 132 | help: 'Comma separated list of specs to skip', 133 | // TODO(eseidel): remove petstore once it doesn't crash. 134 | defaultsTo: ['petstore'], 135 | ); 136 | final results = parser.parse(args); 137 | final verbose = results['verbose'] as bool; 138 | final ignoreList = results['ignore'] as List? ?? []; 139 | final globList = results.rest; 140 | await runWithLogger( 141 | Logger(), 142 | () => run( 143 | verbose: verbose, 144 | skipList: ignoreList, 145 | globList: globList, 146 | ), 147 | ); 148 | } 149 | -------------------------------------------------------------------------------- /lib/src/parse/visitor.dart: -------------------------------------------------------------------------------- 1 | import 'package:space_gen/src/parse/spec.dart'; 2 | 3 | /// Subclass this and override the methods you want to visit. 4 | abstract class Visitor { 5 | void visitHeader(Header header) {} 6 | void visitOperation(Operation operation) {} 7 | void visitParameter(Parameter parameter) {} 8 | void visitPathItem(PathItem pathItem) {} 9 | void visitRefOr(RefOr refOr) {} 10 | void visitRequestBody(RequestBody requestBody) {} 11 | void visitResponse(Response response) {} 12 | void visitRoot(OpenApi root) {} 13 | void visitSchema(Schema schema) {} 14 | } 15 | 16 | // Would be nice if Dart had a generic way to do this, without needing to 17 | // teach the walker about all the types. 18 | class SpecWalker { 19 | SpecWalker(this.visitor); 20 | 21 | final Visitor visitor; 22 | 23 | void walkComponents(Components components) { 24 | _walkRefs(components.schemas.values); 25 | _walkRefs(components.parameters.values); 26 | _walkRefs(components.requestBodies.values); 27 | _walkRefs(components.responses.values); 28 | _walkRefs(components.headers.values); 29 | } 30 | 31 | void _walkRefs(Iterable> refs) { 32 | for (final ref in refs) { 33 | _refOr(ref); 34 | } 35 | } 36 | 37 | void walkRoot(OpenApi root) { 38 | visitor.visitRoot(root); 39 | for (final path in root.paths.paths.values) { 40 | walkPathItem(path); 41 | } 42 | walkComponents(root.components); 43 | } 44 | 45 | void walkPathItem(PathItem pathItem) { 46 | visitor.visitPathItem(pathItem); 47 | // for (final parameter in pathItem.parameters) { 48 | // _parameter(parameter); 49 | // } 50 | for (final operation in pathItem.operations.values) { 51 | _operation(operation); 52 | } 53 | } 54 | 55 | void _operation(Operation operation) { 56 | visitor.visitOperation(operation); 57 | for (final response in operation.responses.responses.values) { 58 | _refOr(response); 59 | } 60 | for (final parameter in operation.parameters) { 61 | _refOr(parameter); 62 | } 63 | _maybeRefOr(operation.requestBody); 64 | } 65 | 66 | void _parameter(Parameter parameter) { 67 | visitor.visitParameter(parameter); 68 | _maybeRefOr(parameter.type); 69 | } 70 | 71 | void _response(Response response) { 72 | visitor.visitResponse(response); 73 | final content = response.content; 74 | if (content != null) { 75 | for (final mediaType in content.values) { 76 | _mediaType(mediaType); 77 | } 78 | } 79 | } 80 | 81 | void _header(Header header) { 82 | visitor.visitHeader(header); 83 | _maybeRefOr(header.schema); 84 | } 85 | 86 | void _maybeRefOr(RefOr? ref) { 87 | if (ref != null) { 88 | _refOr(ref); 89 | } 90 | } 91 | 92 | void _refOr(RefOr refOr) { 93 | visitor.visitRefOr(refOr); 94 | final object = refOr.object; 95 | if (object == null) { 96 | return; 97 | } 98 | if (object is Schema) { 99 | walkSchema(object); 100 | } else if (object is RequestBody) { 101 | _requestBody(object); 102 | } else if (object is Parameter) { 103 | _parameter(object); 104 | } else if (object is Response) { 105 | _response(object); 106 | } else if (object is Header) { 107 | _header(object); 108 | } else { 109 | throw UnimplementedError('Unknown ref type: ${object.runtimeType}'); 110 | } 111 | } 112 | 113 | void _mediaType(MediaType mediaType) { 114 | // visitor.visitMediaType(mediaType); 115 | _refOr(mediaType.schema); 116 | } 117 | 118 | void _requestBody(RequestBody requestBody) { 119 | visitor.visitRequestBody(requestBody); 120 | for (final mediaType in requestBody.content.values) { 121 | _mediaType(mediaType); 122 | } 123 | } 124 | 125 | void walkSchema(Schema schema) { 126 | visitor.visitSchema(schema); 127 | switch (schema) { 128 | case SchemaObject(): 129 | for (final property in schema.properties.values) { 130 | _maybeRefOr(property); 131 | } 132 | _maybeRefOr(schema.additionalProperties); 133 | case SchemaArray(): 134 | _maybeRefOr(schema.items); 135 | case SchemaEnum(): 136 | case SchemaMap(): 137 | case SchemaUnknown(): 138 | case SchemaPod(): 139 | case SchemaInteger(): 140 | case SchemaNumber(): 141 | case SchemaString(): 142 | case SchemaNull(): 143 | case SchemaBinary(): 144 | case SchemaEmptyObject(): 145 | break; 146 | case SchemaCombiner(): 147 | for (final schema in schema.schemas) { 148 | _maybeRefOr(schema); 149 | } 150 | default: 151 | throw UnimplementedError('Unknown schema type: ${schema.runtimeType}'); 152 | } 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![codecov](https://codecov.io/gh/eseidel/space_gen/graph/badge.svg?token=nOnPSYpPXi)](https://codecov.io/gh/eseidel/space_gen) 2 | 3 | # space_gen 4 | 5 | _Generates Dart code so beautiful, it must be from outer space!_ 6 | 7 | A simple, hackable OpenAPI 3.0 and 3.1 generator. 8 | 9 | Implements most of the OpenAPI spec. Patches welcome. 10 | 11 | ## Installing 🧑‍💻 12 | 13 | `space_gen` is typically installed via pub, so you have two options for install: 14 | 15 | ### Option 1: Global install 16 | Short command line (just `space_gen`) for use across all projects: 17 | 18 | ```sh 19 | dart pub global activate space_gen 20 | ``` 21 | 22 | Or install a [specific version](https://pub.dev/packages/space_gen/versions) using: 23 | 24 | ```sh 25 | dart pub global activate space_gen 26 | ``` 27 | 28 | If you haven't already, you might need to [set up your path][path_setup_link]. 29 | 30 | ### Option 2: Per-project install 31 | Useful if you need per-project versions or want everything self-contained. 32 | 33 | ```sh 34 | dart pub add --dev space_gen 35 | ``` 36 | 37 | ```sh 38 | dart run space_gen 39 | ``` 40 | 41 | ## Project Values 42 | 43 | - Generates highest quality, modern Dart code. 44 | - Zero analyzer, formatter or linter errors. Aims to be as good as handwritten. 45 | - Gives readable errors on failure. 46 | - Aspires to handle all of OpenAPI 3.x. 47 | - Generates testable code. 48 | 49 | ## Advantages over Open API Generator 7.0.0 50 | 51 | - Modern null-safe Dart (currently 3.8+ only) 52 | - Generates properly recursive toJson/fromJson which round-trip fully. 53 | - Able to generate immutable models. 54 | - Uses real enum classes. 55 | - Handles oneOf, allOf, anyOf. 56 | - fromJson is non-nullable. 57 | - Generates maybeFromJson with explicit nullability. 58 | - Generates independent classes, which can be imported independently. 59 | - Correctly follows required vs. nullable semantics. 60 | 61 | ## Why not just contribute to OpenAPI? 62 | 63 | This started as a hobby project in August 2023, there were two separate (soon to 64 | be combined?) OpenAPI generators for Dart. One for package:http and one for 65 | package:dio. I only ever used the http one and it had lots of bugs. I looked at 66 | fixing them in OpenAPI, but failed to figure out how to hack on the OpenAPI 67 | generator (Java) or successfully interact with the community (several separate 68 | slacks) so ended up starting my own. Still in 2025 there doesn't seem to be 69 | a consensus towards a better Dart generator, so releasing this one. 70 | 71 | ## Design 72 | 73 | - Loader - Fetches spec urls and imports. 74 | - Parser - Turns Json/Yaml parsed tree into Spec, some validation. 75 | - Resolver - Resolves all references into Resolved\* tree, more validation. 76 | - Renderer - Translates Resolved tree into Render tree. Render tree is passed 77 | off to FileRenderer which uses SchemaRenderer to render api (operation) and 78 | model (schema) files and imports. 79 | 80 | ## Todo 81 | 82 | - Wire up Authentication and sending of bearer header. 83 | - Generate tests. https://github.com/eseidel/space_gen/issues/1 84 | - Fix toString hack for queryParameters. 85 | - Support Parameter.explode. 86 | - Finish oneOf support. 87 | - Remove the 'prop' hack for GitHub spec. 88 | - Split render tree into some sort of "types" library and types -> code gen. 89 | - Support 'default' for objects. 90 | - Make RenderPod and RenderEnum typed. 91 | - Wrap description doc comments to 80c. 92 | - Recursively validations for properties of named schemas (currently only 93 | do top-level const-ready validations). 94 | - Add (non-const) validation during construction of new-type objects, will 95 | require making generator aware that some objects can't be const constructed 96 | as well as adding a const-constructor for those objects for default values. 97 | - Handle 'example' and 'examples' fields. 98 | - explicitly handle format=int64 99 | - Handle format=double and format=float 100 | - Handle deprecated=true in more places (e.g. enums) 101 | - Map & Array newtype via explicitly named schema? 102 | - readOnly and writeOnly 103 | - GitHub: Don't generate workflow_usage_billable_prop_m_a_c_o_s_prop.dart 104 | - Support multi-level references. 105 | - Support detecting/stopping reference cycles. 106 | 107 | ### OpenApi Quirks 108 | 109 | space_gen implements a few OpenAPI quirks to optionally make the generated 110 | output maximally openapi_generator compatible in case you're transitioning 111 | from openapi_generator to space_gen. 112 | 113 | #### Lists default to [] 114 | 115 | OpenAPI makes all List values default to [], and stores all lists as 116 | non-nullable, even if they're nullable (not required) in the spec. This 117 | breaks round-tripping of values, since if your 'list' property is null 118 | (or missing) openapi_generator will parse it as [] and send back []. The 119 | openapi_generator can never send null for a list value. space_gen has this 120 | quirk on by default for now, but it can be disabled. 121 | 122 | #### SCREAMING_CAPS enums 123 | 124 | OpenAPI uses SCREAMING_CAPS enums, this can be enabled in space_gen for easier 125 | transition from openapi_generator to space_gen. By default space_gen will 126 | use lowerCamelCase enums matching Dart style. 127 | 128 | #### Mutable Models 129 | 130 | OpenAPI generates mutable model types and your existing code may depend on 131 | this behavior. space_gen can generate either mutable or immutable model types 132 | and by default makes models immutable. 133 | 134 | [path_setup_link]: https://dart.dev/tools/pub/cmd/pub-global#running-a-script-from-your-path -------------------------------------------------------------------------------- /gen_tests/types/lib/api_client.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'dart:io'; 3 | 4 | import 'package:http/http.dart'; 5 | import 'package:types/api_exception.dart'; 6 | import 'package:types/auth.dart'; 7 | 8 | export 'package:types/auth.dart'; 9 | 10 | /// The HTTP methods supported by the API. 11 | enum Method { 12 | delete, 13 | get, 14 | patch, 15 | post, 16 | put; 17 | 18 | /// Whether the method supports a request body. 19 | bool get supportsBody => this != get && this != delete; 20 | } 21 | 22 | /// The client interface for the API. 23 | /// Subclasses can override [invokeApi] to add custom behavior. 24 | class ApiClient { 25 | ApiClient({ 26 | Uri? baseUri, 27 | Client? client, 28 | this.defaultHeaders = const {}, 29 | this.readSecret, 30 | }) : baseUri = baseUri ?? Uri.parse('https://example.com/types'), 31 | client = client ?? Client(); 32 | 33 | final Uri baseUri; 34 | final Client client; 35 | final Map defaultHeaders; 36 | final String? Function(String name)? readSecret; 37 | 38 | Uri _resolveUri({ 39 | required String path, 40 | required Map queryParameters, 41 | required ResolvedAuth auth, 42 | }) { 43 | // baseUri can contain a path, so we need to resolve the passed path 44 | // relative to it. The passed path will always be absolute (leading slash) 45 | // but should be interpreted as relative to the baseUri. 46 | final uri = Uri.parse('$baseUri$path'); 47 | // baseUri can also include query parameters, so we need to merge them. 48 | final mergedParameters = {...baseUri.queryParameters, ...queryParameters}; 49 | auth.applyToParams(mergedParameters); 50 | return uri.replace(queryParameters: mergedParameters); 51 | } 52 | 53 | Map? _resolveHeaders({ 54 | required bool bodyIsJson, 55 | required ResolvedAuth auth, 56 | required Map headerParameters, 57 | }) { 58 | final maybeContentType = { 59 | ...defaultHeaders, 60 | if (bodyIsJson) 'Content-Type': 'application/json', 61 | ...headerParameters, 62 | }; 63 | 64 | // Apply the auth to the maybeContentType so that headers can still be 65 | // null if we don't have any headers to set. 66 | auth.applyToHeaders(maybeContentType); 67 | 68 | // Just pass null to http if we have no headers to set. 69 | // This makes our calls match openapi (and thus our tests pass). 70 | return maybeContentType.isEmpty ? null : maybeContentType; 71 | } 72 | 73 | /// Resolve an [AuthRequest] into a [ResolvedAuth]. 74 | /// Override this to add custom auth handling. 75 | ResolvedAuth resolveAuth(AuthRequest? authRequest) { 76 | if (authRequest == null) { 77 | return const ResolvedAuth.noAuth(); 78 | } 79 | String? getSecret(String name) => readSecret?.call(name); 80 | return authRequest.resolve(getSecret); 81 | } 82 | 83 | Future invokeApi({ 84 | required Method method, 85 | required String path, 86 | Map queryParameters = const {}, 87 | // Body is nullable to allow for post requests which have an optional body 88 | // to not have to generate two separate calls depending on whether the 89 | // body is present or not. 90 | dynamic body, 91 | Map headerParameters = const {}, 92 | AuthRequest? authRequest, 93 | }) async { 94 | if (!method.supportsBody && body != null) { 95 | throw ArgumentError('Body is not allowed for ${method.name} requests'); 96 | } 97 | 98 | final auth = resolveAuth(authRequest); 99 | final uri = _resolveUri( 100 | path: path, 101 | queryParameters: queryParameters, 102 | auth: auth, 103 | ); 104 | final encodedBody = body != null ? jsonEncode(body) : null; 105 | final headers = _resolveHeaders( 106 | bodyIsJson: encodedBody != null, 107 | headerParameters: headerParameters, 108 | auth: auth, 109 | ); 110 | 111 | try { 112 | switch (method) { 113 | case Method.delete: 114 | return client.delete(uri, headers: headers); 115 | case Method.get: 116 | return client.get(uri, headers: headers); 117 | case Method.patch: 118 | return client.patch(uri, headers: headers, body: encodedBody); 119 | case Method.post: 120 | return client.post(uri, headers: headers, body: encodedBody); 121 | case Method.put: 122 | return client.put(uri, headers: headers, body: encodedBody); 123 | } 124 | } on SocketException catch (error, trace) { 125 | throw ApiException.withInner( 126 | HttpStatus.badRequest, 127 | 'Socket operation failed: $method $path', 128 | error, 129 | trace, 130 | ); 131 | } on TlsException catch (error, trace) { 132 | throw ApiException.withInner( 133 | HttpStatus.badRequest, 134 | 'TLS/SSL communication failed: $method $path', 135 | error, 136 | trace, 137 | ); 138 | } on IOException catch (error, trace) { 139 | throw ApiException.withInner( 140 | HttpStatus.badRequest, 141 | 'I/O operation failed: $method $path', 142 | error, 143 | trace, 144 | ); 145 | } on ClientException catch (error, trace) { 146 | throw ApiException.withInner( 147 | HttpStatus.badRequest, 148 | 'HTTP connection failed: $method $path', 149 | error, 150 | trace, 151 | ); 152 | } on Exception catch (error, trace) { 153 | throw ApiException.withInner( 154 | HttpStatus.badRequest, 155 | 'Exception occurred: $method $path', 156 | error, 157 | trace, 158 | ); 159 | } 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /lib/templates/api_client.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'dart:io'; 3 | 4 | import 'package:http/http.dart'; 5 | import 'package:space_gen/templates/api_exception.dart'; 6 | import 'package:space_gen/templates/auth.dart'; 7 | 8 | export 'package:space_gen/templates/auth.dart'; 9 | 10 | /// The HTTP methods supported by the API. 11 | enum Method { 12 | delete, 13 | get, 14 | patch, 15 | post, 16 | put; 17 | 18 | /// Whether the method supports a request body. 19 | bool get supportsBody => this != get && this != delete; 20 | } 21 | 22 | /// The client interface for the API. 23 | /// Subclasses can override [invokeApi] to add custom behavior. 24 | class ApiClient { 25 | ApiClient({ 26 | Uri? baseUri, 27 | Client? client, 28 | this.defaultHeaders = const {}, 29 | this.readSecret, 30 | }) : baseUri = baseUri ?? Uri.parse('TEMPLATE_BASE_URI'), 31 | client = client ?? Client(); 32 | 33 | final Uri baseUri; 34 | final Client client; 35 | final Map defaultHeaders; 36 | final String? Function(String name)? readSecret; 37 | 38 | Uri _resolveUri({ 39 | required String path, 40 | required Map queryParameters, 41 | required ResolvedAuth auth, 42 | }) { 43 | // baseUri can contain a path, so we need to resolve the passed path 44 | // relative to it. The passed path will always be absolute (leading slash) 45 | // but should be interpreted as relative to the baseUri. 46 | final uri = Uri.parse('$baseUri$path'); 47 | // baseUri can also include query parameters, so we need to merge them. 48 | final mergedParameters = {...baseUri.queryParameters, ...queryParameters}; 49 | auth.applyToParams(mergedParameters); 50 | return uri.replace(queryParameters: mergedParameters); 51 | } 52 | 53 | Map? _resolveHeaders({ 54 | required bool bodyIsJson, 55 | required ResolvedAuth auth, 56 | required Map headerParameters, 57 | }) { 58 | final maybeContentType = { 59 | ...defaultHeaders, 60 | if (bodyIsJson) 'Content-Type': 'application/json', 61 | ...headerParameters, 62 | }; 63 | 64 | // Apply the auth to the maybeContentType so that headers can still be 65 | // null if we don't have any headers to set. 66 | auth.applyToHeaders(maybeContentType); 67 | 68 | // Just pass null to http if we have no headers to set. 69 | // This makes our calls match openapi (and thus our tests pass). 70 | return maybeContentType.isEmpty ? null : maybeContentType; 71 | } 72 | 73 | /// Resolve an [AuthRequest] into a [ResolvedAuth]. 74 | /// Override this to add custom auth handling. 75 | ResolvedAuth resolveAuth(AuthRequest? authRequest) { 76 | if (authRequest == null) { 77 | return const ResolvedAuth.noAuth(); 78 | } 79 | String? getSecret(String name) => readSecret?.call(name); 80 | return authRequest.resolve(getSecret); 81 | } 82 | 83 | Future invokeApi({ 84 | required Method method, 85 | required String path, 86 | Map queryParameters = const {}, 87 | // Body is nullable to allow for post requests which have an optional body 88 | // to not have to generate two separate calls depending on whether the 89 | // body is present or not. 90 | dynamic body, 91 | Map headerParameters = const {}, 92 | AuthRequest? authRequest, 93 | }) async { 94 | if (!method.supportsBody && body != null) { 95 | throw ArgumentError('Body is not allowed for ${method.name} requests'); 96 | } 97 | 98 | final auth = resolveAuth(authRequest); 99 | final uri = _resolveUri( 100 | path: path, 101 | queryParameters: queryParameters, 102 | auth: auth, 103 | ); 104 | final encodedBody = body != null ? jsonEncode(body) : null; 105 | final headers = _resolveHeaders( 106 | bodyIsJson: encodedBody != null, 107 | headerParameters: headerParameters, 108 | auth: auth, 109 | ); 110 | 111 | try { 112 | switch (method) { 113 | case Method.delete: 114 | return client.delete(uri, headers: headers); 115 | case Method.get: 116 | return client.get(uri, headers: headers); 117 | case Method.patch: 118 | return client.patch(uri, headers: headers, body: encodedBody); 119 | case Method.post: 120 | return client.post(uri, headers: headers, body: encodedBody); 121 | case Method.put: 122 | return client.put(uri, headers: headers, body: encodedBody); 123 | } 124 | } on SocketException catch (error, trace) { 125 | throw ApiException.withInner( 126 | HttpStatus.badRequest, 127 | 'Socket operation failed: $method $path', 128 | error, 129 | trace, 130 | ); 131 | } on TlsException catch (error, trace) { 132 | throw ApiException.withInner( 133 | HttpStatus.badRequest, 134 | 'TLS/SSL communication failed: $method $path', 135 | error, 136 | trace, 137 | ); 138 | } on IOException catch (error, trace) { 139 | throw ApiException.withInner( 140 | HttpStatus.badRequest, 141 | 'I/O operation failed: $method $path', 142 | error, 143 | trace, 144 | ); 145 | } on ClientException catch (error, trace) { 146 | throw ApiException.withInner( 147 | HttpStatus.badRequest, 148 | 'HTTP connection failed: $method $path', 149 | error, 150 | trace, 151 | ); 152 | } on Exception catch (error, trace) { 153 | throw ApiException.withInner( 154 | HttpStatus.badRequest, 155 | 'Exception occurred: $method $path', 156 | error, 157 | trace, 158 | ); 159 | } 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /lib/templates/auth.dart: -------------------------------------------------------------------------------- 1 | import 'package:meta/meta.dart'; 2 | 3 | /// An exception thrown when a required secret is missing. 4 | class MissingSecretsException implements Exception { 5 | /// Create a new [MissingSecretsException]. 6 | const MissingSecretsException( 7 | this.auth, [ 8 | this.message = 'Missing required secrets for auth', 9 | ]); 10 | 11 | /// The auth request that is missing secrets. 12 | final AuthRequest auth; 13 | 14 | /// The message of the exception. 15 | final String message; 16 | 17 | @override 18 | String toString() => 'MissingSecretsException: $message'; 19 | } 20 | 21 | /// The resolved headers and parameters for an auth request. 22 | @immutable 23 | class ResolvedAuth { 24 | /// Create a new [ResolvedAuth]. 25 | const ResolvedAuth({required this.headers, required this.params}); 26 | 27 | /// Create a new [ResolvedAuth] with no headers or parameters. 28 | const ResolvedAuth.noAuth() : this(headers: const {}, params: const {}); 29 | 30 | /// The headers of the resolved auth. 31 | final Map headers; 32 | 33 | /// The parameters of the resolved auth. 34 | final Map params; 35 | 36 | /// Apply the resolved auth to the given headers. 37 | void applyToHeaders(Map headers) { 38 | headers.addAll(this.headers); 39 | } 40 | 41 | /// Apply the resolved auth to the given parameters. 42 | void applyToParams(Map params) { 43 | params.addAll(this.params); 44 | } 45 | 46 | /// Merge the given [ResolvedAuth] with the current [ResolvedAuth]. 47 | ResolvedAuth merge(ResolvedAuth other) { 48 | // Should this error when replacing an existing header or parameter? 49 | return ResolvedAuth( 50 | headers: {...headers, ...other.headers}, 51 | params: {...params, ...other.params}, 52 | ); 53 | } 54 | } 55 | 56 | /// An abstract class representing an auth request. 57 | @immutable 58 | abstract class AuthRequest { 59 | const AuthRequest(); 60 | 61 | /// Used to check if the getSecret function has all the secrets for this auth. 62 | bool haveSecrets(String? Function(String name) getSecret); 63 | 64 | /// Used to resolve the auth using the getSecret function. 65 | ResolvedAuth resolve(String? Function(String name) getSecret); 66 | } 67 | 68 | /// An auth request that does nothing. 69 | @immutable 70 | class NoAuth extends AuthRequest { 71 | /// Create a new [NoAuth]. 72 | const NoAuth(); 73 | 74 | /// Always returns true since no auth is required. 75 | @override 76 | bool haveSecrets(String? Function(String name) getSecret) => true; 77 | 78 | /// Returns a [ResolvedAuth] with no headers or parameters. 79 | @override 80 | ResolvedAuth resolve(String? Function(String name) getSecret) => 81 | const ResolvedAuth.noAuth(); 82 | } 83 | 84 | /// An auth request that is a one of any of the given auth requests. 85 | @immutable 86 | class OneOfAuth extends AuthRequest { 87 | const OneOfAuth(this.auths); 88 | 89 | /// The auth requests that are a one of. 90 | final List auths; 91 | 92 | /// Returns true if any of the auth requests have all the secrets. 93 | @override 94 | bool haveSecrets(String? Function(String name) getSecret) => 95 | auths.any((e) => e.haveSecrets(getSecret)); 96 | 97 | /// Returns the resolved auth for the first auth request that has all 98 | /// necessary secrets. 99 | @override 100 | ResolvedAuth resolve(String? Function(String name) getSecret) { 101 | // Walk the auths in order, see if we have all secrets for any of them 102 | // if so, return the resolved auth for that auth. 103 | for (final auth in auths) { 104 | if (auth.haveSecrets(getSecret)) { 105 | return auth.resolve(getSecret); 106 | } 107 | } 108 | // If we get here, we don't have all the secrets for any of the auths. 109 | return throw MissingSecretsException(this); 110 | } 111 | } 112 | 113 | /// An auth request that is all of any of the given auth requests. 114 | @immutable 115 | class AllOfAuth extends AuthRequest { 116 | /// Create a new [AllOfAuth]. 117 | const AllOfAuth(this.auths); 118 | 119 | /// The auth requests that are all of. 120 | final List auths; 121 | 122 | /// Returns true if all of the auth requests have all the secrets. 123 | @override 124 | bool haveSecrets(String? Function(String name) getSecret) => 125 | auths.every((e) => e.haveSecrets(getSecret)); 126 | 127 | /// Returns the resolved auth for the first auth request that has all 128 | /// necessary secrets. 129 | @override 130 | ResolvedAuth resolve(String? Function(String name) getSecret) { 131 | var resolved = const ResolvedAuth.noAuth(); 132 | for (final auth in auths) { 133 | resolved = resolved.merge(auth.resolve(getSecret)); 134 | } 135 | return resolved; 136 | } 137 | } 138 | 139 | /// An auth request that requires a secret. 140 | @immutable 141 | abstract class SecretAuth extends AuthRequest { 142 | const SecretAuth({required this.secretName}); 143 | 144 | /// The name of the secret. 145 | final String secretName; 146 | 147 | @override 148 | bool haveSecrets(String? Function(String name) getSecret) => 149 | getSecret(secretName) != null; 150 | } 151 | 152 | /// An HTTP authentication scheme. 153 | @immutable 154 | class HttpAuth extends SecretAuth { 155 | /// Create a new HTTP authentication scheme. 156 | const HttpAuth({required this.scheme, required super.secretName}); 157 | 158 | /// The scheme to use when sending the HTTP token. 159 | final String scheme; 160 | 161 | @override 162 | ResolvedAuth resolve(String? Function(String name) getSecret) { 163 | final secret = getSecret(secretName); 164 | if (secret == null) { 165 | throw MissingSecretsException(this); 166 | } 167 | return ResolvedAuth( 168 | headers: {'Authorization': '$scheme $secret'}, 169 | params: const {}, 170 | ); 171 | } 172 | } 173 | 174 | // Cookie is not yet supported. 175 | enum ApiKeyLocation { query, header } 176 | 177 | /// An API key authentication scheme. 178 | @immutable 179 | class ApiKeyAuth extends SecretAuth { 180 | const ApiKeyAuth({ 181 | required this.name, 182 | required this.sendIn, 183 | required super.secretName, 184 | }); 185 | 186 | /// The key name used when sending the API key. 187 | final String name; 188 | 189 | /// Where to send the API key. 190 | final ApiKeyLocation sendIn; 191 | 192 | @override 193 | ResolvedAuth resolve(String? Function(String name) getSecret) { 194 | final secret = getSecret(secretName); 195 | if (secret == null) { 196 | throw MissingSecretsException(this); 197 | } 198 | return switch (sendIn) { 199 | ApiKeyLocation.header => ResolvedAuth( 200 | headers: {name: secret}, 201 | params: const {}, 202 | ), 203 | ApiKeyLocation.query => ResolvedAuth( 204 | headers: const {}, 205 | params: {name: secret}, 206 | ), 207 | }; 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /gen_tests/types/lib/auth.dart: -------------------------------------------------------------------------------- 1 | import 'package:meta/meta.dart'; 2 | 3 | /// An exception thrown when a required secret is missing. 4 | class MissingSecretsException implements Exception { 5 | /// Create a new [MissingSecretsException]. 6 | const MissingSecretsException( 7 | this.auth, [ 8 | this.message = 'Missing required secrets for auth', 9 | ]); 10 | 11 | /// The auth request that is missing secrets. 12 | final AuthRequest auth; 13 | 14 | /// The message of the exception. 15 | final String message; 16 | 17 | @override 18 | String toString() => 'MissingSecretsException: $message'; 19 | } 20 | 21 | /// The resolved headers and parameters for an auth request. 22 | @immutable 23 | class ResolvedAuth { 24 | /// Create a new [ResolvedAuth]. 25 | const ResolvedAuth({required this.headers, required this.params}); 26 | 27 | /// Create a new [ResolvedAuth] with no headers or parameters. 28 | const ResolvedAuth.noAuth() : this(headers: const {}, params: const {}); 29 | 30 | /// The headers of the resolved auth. 31 | final Map headers; 32 | 33 | /// The parameters of the resolved auth. 34 | final Map params; 35 | 36 | /// Apply the resolved auth to the given headers. 37 | void applyToHeaders(Map headers) { 38 | headers.addAll(this.headers); 39 | } 40 | 41 | /// Apply the resolved auth to the given parameters. 42 | void applyToParams(Map params) { 43 | params.addAll(this.params); 44 | } 45 | 46 | /// Merge the given [ResolvedAuth] with the current [ResolvedAuth]. 47 | ResolvedAuth merge(ResolvedAuth other) { 48 | // Should this error when replacing an existing header or parameter? 49 | return ResolvedAuth( 50 | headers: {...headers, ...other.headers}, 51 | params: {...params, ...other.params}, 52 | ); 53 | } 54 | } 55 | 56 | /// An abstract class representing an auth request. 57 | @immutable 58 | abstract class AuthRequest { 59 | const AuthRequest(); 60 | 61 | /// Used to check if the getSecret function has all the secrets for this auth. 62 | bool haveSecrets(String? Function(String name) getSecret); 63 | 64 | /// Used to resolve the auth using the getSecret function. 65 | ResolvedAuth resolve(String? Function(String name) getSecret); 66 | } 67 | 68 | /// An auth request that does nothing. 69 | @immutable 70 | class NoAuth extends AuthRequest { 71 | /// Create a new [NoAuth]. 72 | const NoAuth(); 73 | 74 | /// Always returns true since no auth is required. 75 | @override 76 | bool haveSecrets(String? Function(String name) getSecret) => true; 77 | 78 | /// Returns a [ResolvedAuth] with no headers or parameters. 79 | @override 80 | ResolvedAuth resolve(String? Function(String name) getSecret) => 81 | const ResolvedAuth.noAuth(); 82 | } 83 | 84 | /// An auth request that is a one of any of the given auth requests. 85 | @immutable 86 | class OneOfAuth extends AuthRequest { 87 | const OneOfAuth(this.auths); 88 | 89 | /// The auth requests that are a one of. 90 | final List auths; 91 | 92 | /// Returns true if any of the auth requests have all the secrets. 93 | @override 94 | bool haveSecrets(String? Function(String name) getSecret) => 95 | auths.any((e) => e.haveSecrets(getSecret)); 96 | 97 | /// Returns the resolved auth for the first auth request that has all 98 | /// necessary secrets. 99 | @override 100 | ResolvedAuth resolve(String? Function(String name) getSecret) { 101 | // Walk the auths in order, see if we have all secrets for any of them 102 | // if so, return the resolved auth for that auth. 103 | for (final auth in auths) { 104 | if (auth.haveSecrets(getSecret)) { 105 | return auth.resolve(getSecret); 106 | } 107 | } 108 | // If we get here, we don't have all the secrets for any of the auths. 109 | return throw MissingSecretsException(this); 110 | } 111 | } 112 | 113 | /// An auth request that is all of any of the given auth requests. 114 | @immutable 115 | class AllOfAuth extends AuthRequest { 116 | /// Create a new [AllOfAuth]. 117 | const AllOfAuth(this.auths); 118 | 119 | /// The auth requests that are all of. 120 | final List auths; 121 | 122 | /// Returns true if all of the auth requests have all the secrets. 123 | @override 124 | bool haveSecrets(String? Function(String name) getSecret) => 125 | auths.every((e) => e.haveSecrets(getSecret)); 126 | 127 | /// Returns the resolved auth for the first auth request that has all 128 | /// necessary secrets. 129 | @override 130 | ResolvedAuth resolve(String? Function(String name) getSecret) { 131 | var resolved = const ResolvedAuth.noAuth(); 132 | for (final auth in auths) { 133 | resolved = resolved.merge(auth.resolve(getSecret)); 134 | } 135 | return resolved; 136 | } 137 | } 138 | 139 | /// An auth request that requires a secret. 140 | @immutable 141 | abstract class SecretAuth extends AuthRequest { 142 | const SecretAuth({required this.secretName}); 143 | 144 | /// The name of the secret. 145 | final String secretName; 146 | 147 | @override 148 | bool haveSecrets(String? Function(String name) getSecret) => 149 | getSecret(secretName) != null; 150 | } 151 | 152 | /// An HTTP authentication scheme. 153 | @immutable 154 | class HttpAuth extends SecretAuth { 155 | /// Create a new HTTP authentication scheme. 156 | const HttpAuth({required this.scheme, required super.secretName}); 157 | 158 | /// The scheme to use when sending the HTTP token. 159 | final String scheme; 160 | 161 | @override 162 | ResolvedAuth resolve(String? Function(String name) getSecret) { 163 | final secret = getSecret(secretName); 164 | if (secret == null) { 165 | throw MissingSecretsException(this); 166 | } 167 | return ResolvedAuth( 168 | headers: {'Authorization': '$scheme $secret'}, 169 | params: const {}, 170 | ); 171 | } 172 | } 173 | 174 | // Cookie is not yet supported. 175 | enum ApiKeyLocation { query, header } 176 | 177 | /// An API key authentication scheme. 178 | @immutable 179 | class ApiKeyAuth extends SecretAuth { 180 | const ApiKeyAuth({ 181 | required this.name, 182 | required this.sendIn, 183 | required super.secretName, 184 | }); 185 | 186 | /// The key name used when sending the API key. 187 | final String name; 188 | 189 | /// Where to send the API key. 190 | final ApiKeyLocation sendIn; 191 | 192 | @override 193 | ResolvedAuth resolve(String? Function(String name) getSecret) { 194 | final secret = getSecret(secretName); 195 | if (secret == null) { 196 | throw MissingSecretsException(this); 197 | } 198 | return switch (sendIn) { 199 | ApiKeyLocation.header => ResolvedAuth( 200 | headers: {name: secret}, 201 | params: const {}, 202 | ), 203 | ApiKeyLocation.query => ResolvedAuth( 204 | headers: const {}, 205 | params: {name: secret}, 206 | ), 207 | }; 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /lib/src/types.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | import 'package:meta/meta.dart'; 3 | 4 | /// The "in" of a parameter. "in" is a keyword in Dart, so we use SendIn. 5 | /// e.g. query, header, path, cookie. 6 | /// https://spec.openapis.org/oas/v3.0.0#parameter-object 7 | enum ParameterLocation { 8 | /// The query parameter is a parameter that is sent in the query string. 9 | query, 10 | 11 | /// The header parameter is a parameter that is sent in the header. 12 | header, 13 | 14 | /// The path parameter is a parameter that is sent in the path. 15 | path, 16 | 17 | /// The cookie parameter is a parameter that is sent in the cookie. 18 | cookie, 19 | } 20 | 21 | /// A method is a http method. 22 | /// https://spec.openapis.org/oas/v3.0.0#operation-object 23 | enum Method { 24 | /// The GET method is used to retrieve a resource. 25 | get, 26 | 27 | /// The POST method is used to create a resource. 28 | post, 29 | 30 | /// The PUT method is used to update a resource. 31 | put, 32 | 33 | /// The DELETE method is used to delete a resource. 34 | delete, 35 | 36 | /// The PATCH method is used to update a resource. 37 | patch, 38 | 39 | /// The HEAD method is used to get the headers of a resource. 40 | head, 41 | 42 | /// The OPTIONS method is used to get the supported methods of a resource. 43 | options, 44 | 45 | /// The TRACE method is used to get the trace of a resource. 46 | trace; 47 | 48 | /// The method as a lowercase string. 49 | String get key => name.toLowerCase(); 50 | } 51 | 52 | /// Json pointer is a string that can be used to reference a value in a json 53 | /// object. 54 | /// https://spec.openapis.org/oas/v3.1.0#json-pointer 55 | @immutable 56 | class JsonPointer extends Equatable { 57 | const JsonPointer.empty() : parts = const []; 58 | 59 | /// Create a new JsonPointer from a list of parts. 60 | const JsonPointer.fromParts(this.parts); 61 | 62 | /// Create a new JsonPointer from a string. 63 | factory JsonPointer.parse(String string) { 64 | if (!string.startsWith('#/')) { 65 | throw FormatException('Invalid json pointer: $string'); 66 | } 67 | return JsonPointer.fromParts(string.substring(2).split('/')); 68 | } 69 | 70 | JsonPointer add(String part) => JsonPointer.fromParts([...parts, part]); 71 | 72 | /// The parts of the json pointer. 73 | final List parts; 74 | 75 | String get urlEncodedFragment { 76 | /// This pointer encoded as a url-ready string. 77 | /// e.g. #/components/schemas/User 78 | return '#/${parts.map(urlEncode).join('/')}'; 79 | } 80 | 81 | /// Encode a part of the json pointer as a url-ready string. 82 | static String urlEncode(String part) => 83 | part.replaceAll('~', '~0').replaceAll('/', '~1'); 84 | 85 | @override 86 | String toString() => urlEncodedFragment; 87 | 88 | @override 89 | List get props => [parts]; 90 | } 91 | 92 | /// Known supported mime types for this library. 93 | enum MimeType { 94 | applicationJson('application/json'), 95 | applicationOctetStream('application/octet-stream'), 96 | textPlain('text/plain'); 97 | 98 | const MimeType(this.value); 99 | 100 | final String value; 101 | } 102 | 103 | enum PodType { boolean, dateTime, uri, uriTemplate, email, date } 104 | 105 | /// Properties that are common to all schemas and across 106 | /// parsing, resolution and rendering. This just saves a lot of boilerplate. 107 | class CommonProperties extends Equatable { 108 | const CommonProperties({ 109 | required this.pointer, 110 | required this.snakeName, 111 | required this.title, 112 | required this.description, 113 | required this.isDeprecated, 114 | required this.nullable, 115 | required this.example, 116 | required this.examples, 117 | }); 118 | 119 | /// A common properties object with no additional properties. 120 | /// Used for implicit schemas. 121 | const CommonProperties.empty({required this.pointer, required this.snakeName}) 122 | : title = null, 123 | description = null, 124 | isDeprecated = false, 125 | nullable = false, 126 | example = null, 127 | examples = null; 128 | 129 | /// Tests don't need to specify all properties. 130 | const CommonProperties.test({ 131 | required this.pointer, 132 | required this.snakeName, 133 | this.title, 134 | this.description, 135 | this.isDeprecated = false, 136 | this.nullable = false, 137 | this.example, 138 | this.examples, 139 | }); 140 | 141 | /// The location of the schema in the spec. 142 | final JsonPointer pointer; 143 | 144 | /// The snake name of the schema. 145 | final String snakeName; 146 | 147 | /// The description of the schema. 148 | final String? title; 149 | 150 | /// The description of the schema. 151 | final String? description; 152 | 153 | /// Whether the schema is deprecated. 154 | final bool isDeprecated; 155 | 156 | /// Whether the schema is nullable. 157 | final bool nullable; 158 | 159 | // TODO(eseidel): Make these strongly typed. 160 | /// An example of the schema. 161 | final dynamic example; 162 | 163 | /// Examples of the schema. 164 | final List? examples; 165 | 166 | CommonProperties copyWith({ 167 | JsonPointer? pointer, 168 | String? snakeName, 169 | String? title, 170 | String? description, 171 | bool? isDeprecated, 172 | bool? nullable, 173 | dynamic example, 174 | List? examples, 175 | }) { 176 | return CommonProperties( 177 | pointer: pointer ?? this.pointer, 178 | snakeName: snakeName ?? this.snakeName, 179 | title: title ?? this.title, 180 | description: description ?? this.description, 181 | isDeprecated: isDeprecated ?? this.isDeprecated, 182 | nullable: nullable ?? this.nullable, 183 | example: example ?? this.example, 184 | examples: examples ?? this.examples, 185 | ); 186 | } 187 | 188 | @override 189 | List get props => [ 190 | pointer, 191 | snakeName, 192 | title, 193 | description, 194 | isDeprecated, 195 | nullable, 196 | ]; 197 | } 198 | 199 | // Security schemes do not need a Resolved or Render variant, so sharing 200 | // them via this file. 201 | @immutable 202 | sealed class SecurityScheme extends Equatable { 203 | const SecurityScheme({ 204 | required this.description, 205 | required this.name, 206 | required this.pointer, 207 | }); 208 | 209 | /// The location of the scheme in the spec. 210 | final JsonPointer pointer; 211 | 212 | // Name as it appears in the component map of the spec. 213 | final String name; 214 | 215 | /// Description of the security scheme, mostly for documentation. 216 | final String? description; 217 | 218 | @override 219 | List get props => [description, name]; 220 | } 221 | 222 | enum ApiKeyLocation { header, query, cookie } 223 | 224 | /// A api key security scheme. 225 | @immutable 226 | class ApiKeySecurityScheme extends SecurityScheme { 227 | const ApiKeySecurityScheme({ 228 | required super.pointer, 229 | required super.name, 230 | required super.description, 231 | required this.keyName, 232 | required this.inLocation, 233 | }); 234 | 235 | /// Name of the api key, used as a query param or header. 236 | final String keyName; 237 | 238 | /// Where to send the api key in the request. 239 | final ApiKeyLocation inLocation; 240 | @override 241 | List get props => [super.props, name, inLocation]; 242 | } 243 | 244 | /// A http security scheme. 245 | @immutable 246 | class HttpSecurityScheme extends SecurityScheme { 247 | const HttpSecurityScheme({ 248 | required super.pointer, 249 | required super.name, 250 | required super.description, 251 | required this.scheme, 252 | required this.bearerFormat, 253 | }); 254 | 255 | /// The scheme of the http security scheme. 256 | final String scheme; 257 | 258 | /// The bearer format of the http security scheme. 259 | final String? bearerFormat; 260 | 261 | @override 262 | List get props => [super.props, scheme, bearerFormat]; 263 | } 264 | -------------------------------------------------------------------------------- /lib/src/render.dart: -------------------------------------------------------------------------------- 1 | import 'package:file/file.dart'; 2 | import 'package:meta/meta.dart'; 3 | import 'package:path/path.dart' as p; 4 | import 'package:space_gen/src/loader.dart'; 5 | import 'package:space_gen/src/logger.dart'; 6 | import 'package:space_gen/src/parse/spec.dart'; 7 | import 'package:space_gen/src/parse/visitor.dart'; 8 | import 'package:space_gen/src/parser.dart'; 9 | import 'package:space_gen/src/quirks.dart'; 10 | import 'package:space_gen/src/render/file_renderer.dart'; 11 | import 'package:space_gen/src/render/render_tree.dart'; 12 | import 'package:space_gen/src/render/schema_renderer.dart'; 13 | import 'package:space_gen/src/render/templates.dart'; 14 | import 'package:space_gen/src/resolver.dart'; 15 | import 'package:space_gen/src/string.dart'; 16 | 17 | export 'package:space_gen/src/quirks.dart'; 18 | 19 | class _RefCollector extends Visitor { 20 | _RefCollector(this._refs); 21 | 22 | final Set> _refs; 23 | 24 | @override 25 | void visitRefOr(RefOr refOr) { 26 | if (refOr.ref != null) { 27 | _refs.add(refOr.ref!); 28 | } 29 | } 30 | } 31 | 32 | Iterable> collectRefs(OpenApi root) { 33 | final refs = >{}; 34 | final collector = _RefCollector(refs); 35 | SpecWalker(collector).walkRoot(root); 36 | return refs; 37 | } 38 | 39 | void validatePackageName(String packageName) { 40 | // Validate that packageName is a valid dart package name. 41 | // Should be snake_case starting with a letter. 42 | final validRegexp = RegExp(r'^[a-z][a-z0-9_]{0,63}$'); 43 | if (!validRegexp.hasMatch(packageName)) { 44 | throw FormatException('"$packageName" is not a valid dart package name.'); 45 | } 46 | } 47 | 48 | Future loadAndRenderSpec({ 49 | required Uri specUrl, 50 | required String packageName, 51 | required Directory outDir, 52 | required Directory templatesDir, 53 | RunProcess? runProcess, 54 | Quirks quirks = const Quirks(), 55 | bool logSchemas = true, 56 | }) async { 57 | final fs = outDir.fileSystem; 58 | validatePackageName(packageName); 59 | 60 | final templates = TemplateProvider.fromDirectory(templatesDir); 61 | 62 | // Load the spec and warm the cache before rendering. 63 | final cache = Cache(fs); 64 | final specJson = await cache.load(specUrl); 65 | final spec = parseOpenApi(specJson); 66 | 67 | // Pre-warm the cache. Rendering assumes all refs are present in the cache. 68 | for (final ref in collectRefs(spec)) { 69 | // We need to walk all the refs and get type and location. 70 | // We load the locations, and then parse them as the types. 71 | // And then stick them in the resolver cache. 72 | 73 | // If any of the refs are network urls, we need to fetch them. 74 | // The cache does not handle fragments, so we need to remove them. 75 | final resolved = specUrl.resolveUri(ref.uri).removeFragment(); 76 | await cache.load(resolved); 77 | } 78 | 79 | // Resolve all references in the spec. 80 | final resolved = resolveSpec(spec, specUrl: specUrl); 81 | final resolver = SpecResolver(quirks); 82 | // Convert the resolved spec into render objects. 83 | final renderSpec = resolver.toRenderSpec(resolved); 84 | // SchemaRenderer is responsible for rendering schemas and APIs into strings. 85 | final schemaRenderer = SchemaRenderer(templates: templates, quirks: quirks); 86 | 87 | final specPathString = specUrl.isScheme('file') 88 | ? p.relative(specUrl.toFilePath()) 89 | : specUrl.toString(); 90 | final outDirPathString = p.relative(outDir.path); 91 | logger.info('Generating $specPathString to $outDirPathString'); 92 | 93 | final formatter = Formatter(runProcess: runProcess); 94 | final spellChecker = SpellChecker(runProcess: runProcess); 95 | final fileWriter = FileWriter(outDir: outDir); 96 | 97 | // FileRenderer is responsible for deciding the layout of the files 98 | // and rendering the rest of directory structure. 99 | FileRenderer( 100 | packageName: packageName, 101 | templates: templates, 102 | schemaRenderer: schemaRenderer, 103 | formatter: formatter, 104 | fileWriter: fileWriter, 105 | spellChecker: spellChecker, 106 | ).render(renderSpec); 107 | } 108 | 109 | @visibleForTesting 110 | String renderTestSchema( 111 | Map schemaJson, { 112 | String schemaName = 'test', 113 | Quirks quirks = const Quirks(), 114 | bool asComponent = false, 115 | }) { 116 | final MapContext context; 117 | // If asComponent is true, we need to parse the schema as though it were 118 | // defined in #/components/schemas/schemaName, this is used to make 119 | // hasExplicitName return true, and thus the schema be rendered as a 120 | // separate class. 121 | if (asComponent) { 122 | context = MapContext( 123 | pointerParts: ['components', 'schemas', schemaName], 124 | snakeNameStack: [schemaName], 125 | json: schemaJson, 126 | ); 127 | } else { 128 | // Otherwise parse as though the schema was defined in the root 129 | // (which isn't realistic, but makes for short pointers). 130 | context = MapContext.initial(schemaJson).addSnakeName(schemaName); 131 | } 132 | final parsedSchema = parseSchema(context); 133 | final resolvedSchema = resolveSchemaRef( 134 | SchemaRef.object(parsedSchema, const JsonPointer.empty()), 135 | ResolveContext.test(), 136 | ); 137 | final resolver = SpecResolver(quirks); 138 | final templates = TemplateProvider.defaultLocation(); 139 | 140 | final renderSchema = resolver.toRenderSchema(resolvedSchema); 141 | final schemaRenderer = SchemaRenderer(templates: templates, quirks: quirks); 142 | return schemaRenderer.renderSchema(renderSchema); 143 | } 144 | 145 | /// Render a set of schemas to separate strings. 146 | @visibleForTesting 147 | Map renderTestSchemas( 148 | Map> schemas, { 149 | required Uri specUrl, 150 | Quirks quirks = const Quirks(), 151 | }) { 152 | final schemasContext = MapContext( 153 | pointerParts: ['components', 'schemas'], 154 | snakeNameStack: [], 155 | json: schemas, 156 | ); 157 | final parsedSchemas = schemas.map((key, value) { 158 | final context = MapContext.fromParent( 159 | parent: schemasContext, 160 | json: value, 161 | key: key, 162 | ).addSnakeName(key); 163 | return MapEntry(key, parseSchema(context)); 164 | }); 165 | 166 | final refRegistry = RefRegistry(); 167 | void add(HasPointer object) { 168 | final fragment = object.pointer.urlEncodedFragment; 169 | final uri = specUrl.resolve(fragment); 170 | refRegistry.register(uri, object); 171 | } 172 | 173 | for (final parsedSchema in parsedSchemas.values) { 174 | add(parsedSchema); 175 | } 176 | final resolveContext = ResolveContext( 177 | specUrl: specUrl, 178 | refRegistry: refRegistry, 179 | globalSecurityRequirements: const [], 180 | securitySchemes: const [], 181 | ); 182 | 183 | final resolvedSchemas = parsedSchemas.map((key, value) { 184 | return MapEntry( 185 | key, 186 | resolveSchemaRef( 187 | SchemaRef.object(value, const JsonPointer.empty()), 188 | resolveContext, 189 | ), 190 | ); 191 | }); 192 | final resolver = SpecResolver(quirks); 193 | final templates = TemplateProvider.defaultLocation(); 194 | 195 | final renderSchemas = resolvedSchemas.map((key, value) { 196 | final renderSchema = resolver.toRenderSchema(value); 197 | final schemaRenderer = SchemaRenderer(templates: templates, quirks: quirks); 198 | return MapEntry(key, schemaRenderer.renderSchema(renderSchema)); 199 | }); 200 | 201 | return renderSchemas; 202 | } 203 | 204 | @visibleForTesting 205 | String renderTestOperation({ 206 | required String path, 207 | required Map operationJson, 208 | required Uri serverUrl, 209 | Map? componentsJson, 210 | Quirks quirks = const Quirks(), 211 | String? removePrefix, 212 | }) { 213 | final specUrl = Uri.parse('https://example.com/spec'); 214 | final refRegistry = RefRegistry(); 215 | List? securitySchemes; 216 | if (componentsJson != null) { 217 | final parsedComponents = parseComponents( 218 | MapContext.initial(componentsJson), 219 | ); 220 | final builder = RegistryBuilder(specUrl, refRegistry); 221 | SpecWalker(builder).walkComponents(parsedComponents); 222 | securitySchemes = parsedComponents.securitySchemes; 223 | } 224 | final resolveContext = ResolveContext.test( 225 | refRegistry: refRegistry, 226 | securitySchemes: securitySchemes, 227 | ); 228 | final parsedOperation = parseOperation( 229 | MapContext.initial(operationJson), 230 | path, 231 | ); 232 | final resolvedOperation = resolveOperation( 233 | path: path, 234 | method: Method.post, 235 | operation: parsedOperation, 236 | context: resolveContext, 237 | ); 238 | final resolver = SpecResolver(quirks); 239 | final renderOperation = resolver.toRenderOperation(resolvedOperation); 240 | final templateProvider = TemplateProvider.defaultLocation(); 241 | final schemaRenderer = SchemaRenderer( 242 | templates: templateProvider, 243 | quirks: quirks, 244 | ); 245 | final tag = resolvedOperation.tags.firstOrNull ?? 'Default'; 246 | final className = '${tag.capitalizeFirst()}Api'; 247 | final endpoint = Endpoint(serverUrl: serverUrl, operation: renderOperation); 248 | return schemaRenderer.renderEndpoints( 249 | description: 'Test API', 250 | className: className, 251 | endpoints: [endpoint], 252 | removePrefix: removePrefix, 253 | ); 254 | } 255 | 256 | /// Render the first api from a complete spec. 257 | /// This is mostly useful for testing tags which are not part of the operation 258 | /// and need to be looked up from the root spec tags list. 259 | @visibleForTesting 260 | String renderTestApiFromSpec({ 261 | required Map specJson, 262 | required Uri serverUrl, 263 | required Uri specUrl, 264 | Quirks quirks = const Quirks(), 265 | }) { 266 | final spec = parseOpenApi(specJson); 267 | final resolvedSpec = resolveSpec(spec, specUrl: specUrl, logSchemas: false); 268 | final renderSpec = SpecResolver(quirks).toRenderSpec(resolvedSpec); 269 | final api = renderSpec.apis.first; 270 | final templateProvider = TemplateProvider.defaultLocation(); 271 | final schemaRenderer = SchemaRenderer( 272 | templates: templateProvider, 273 | quirks: quirks, 274 | ); 275 | return schemaRenderer.renderApi(api); 276 | } 277 | -------------------------------------------------------------------------------- /pubspec.lock: -------------------------------------------------------------------------------- 1 | # Generated by pub 2 | # See https://dart.dev/tools/pub/glossary#lockfile 3 | packages: 4 | _fe_analyzer_shared: 5 | dependency: transitive 6 | description: 7 | name: _fe_analyzer_shared 8 | sha256: f0bb5d1648339c8308cc0b9838d8456b3cfe5c91f9dc1a735b4d003269e5da9a 9 | url: "https://pub.dev" 10 | source: hosted 11 | version: "88.0.0" 12 | analyzer: 13 | dependency: transitive 14 | description: 15 | name: analyzer 16 | sha256: "0b7b9c329d2879f8f05d6c05b32ee9ec025f39b077864bdb5ac9a7b63418a98f" 17 | url: "https://pub.dev" 18 | source: hosted 19 | version: "8.1.1" 20 | args: 21 | dependency: "direct main" 22 | description: 23 | name: args 24 | sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 25 | url: "https://pub.dev" 26 | source: hosted 27 | version: "2.7.0" 28 | async: 29 | dependency: transitive 30 | description: 31 | name: async 32 | sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" 33 | url: "https://pub.dev" 34 | source: hosted 35 | version: "2.13.0" 36 | boolean_selector: 37 | dependency: transitive 38 | description: 39 | name: boolean_selector 40 | sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" 41 | url: "https://pub.dev" 42 | source: hosted 43 | version: "2.1.2" 44 | cli_config: 45 | dependency: transitive 46 | description: 47 | name: cli_config 48 | sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec 49 | url: "https://pub.dev" 50 | source: hosted 51 | version: "0.2.0" 52 | collection: 53 | dependency: "direct main" 54 | description: 55 | name: collection 56 | sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" 57 | url: "https://pub.dev" 58 | source: hosted 59 | version: "1.19.1" 60 | convert: 61 | dependency: transitive 62 | description: 63 | name: convert 64 | sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 65 | url: "https://pub.dev" 66 | source: hosted 67 | version: "3.1.2" 68 | coverage: 69 | dependency: transitive 70 | description: 71 | name: coverage 72 | sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" 73 | url: "https://pub.dev" 74 | source: hosted 75 | version: "1.15.0" 76 | crypto: 77 | dependency: transitive 78 | description: 79 | name: crypto 80 | sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" 81 | url: "https://pub.dev" 82 | source: hosted 83 | version: "3.0.6" 84 | equatable: 85 | dependency: "direct main" 86 | description: 87 | name: equatable 88 | sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" 89 | url: "https://pub.dev" 90 | source: hosted 91 | version: "2.0.7" 92 | ffi: 93 | dependency: transitive 94 | description: 95 | name: ffi 96 | sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" 97 | url: "https://pub.dev" 98 | source: hosted 99 | version: "2.1.4" 100 | file: 101 | dependency: "direct main" 102 | description: 103 | name: file 104 | sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 105 | url: "https://pub.dev" 106 | source: hosted 107 | version: "7.0.1" 108 | frontend_server_client: 109 | dependency: transitive 110 | description: 111 | name: frontend_server_client 112 | sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 113 | url: "https://pub.dev" 114 | source: hosted 115 | version: "4.0.0" 116 | glob: 117 | dependency: transitive 118 | description: 119 | name: glob 120 | sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de 121 | url: "https://pub.dev" 122 | source: hosted 123 | version: "2.1.3" 124 | http: 125 | dependency: "direct main" 126 | description: 127 | name: http 128 | sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007 129 | url: "https://pub.dev" 130 | source: hosted 131 | version: "1.5.0" 132 | http_multi_server: 133 | dependency: transitive 134 | description: 135 | name: http_multi_server 136 | sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 137 | url: "https://pub.dev" 138 | source: hosted 139 | version: "3.2.2" 140 | http_parser: 141 | dependency: transitive 142 | description: 143 | name: http_parser 144 | sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" 145 | url: "https://pub.dev" 146 | source: hosted 147 | version: "4.1.2" 148 | io: 149 | dependency: transitive 150 | description: 151 | name: io 152 | sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b 153 | url: "https://pub.dev" 154 | source: hosted 155 | version: "1.0.5" 156 | js: 157 | dependency: transitive 158 | description: 159 | name: js 160 | sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" 161 | url: "https://pub.dev" 162 | source: hosted 163 | version: "0.7.2" 164 | lints: 165 | dependency: "direct dev" 166 | description: 167 | name: lints 168 | sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0 169 | url: "https://pub.dev" 170 | source: hosted 171 | version: "6.0.0" 172 | logging: 173 | dependency: transitive 174 | description: 175 | name: logging 176 | sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 177 | url: "https://pub.dev" 178 | source: hosted 179 | version: "1.3.0" 180 | mason_logger: 181 | dependency: "direct main" 182 | description: 183 | name: mason_logger 184 | sha256: "6d5a989ff41157915cb5162ed6e41196d5e31b070d2f86e1c2edf216996a158c" 185 | url: "https://pub.dev" 186 | source: hosted 187 | version: "0.3.3" 188 | matcher: 189 | dependency: transitive 190 | description: 191 | name: matcher 192 | sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 193 | url: "https://pub.dev" 194 | source: hosted 195 | version: "0.12.17" 196 | meta: 197 | dependency: "direct main" 198 | description: 199 | name: meta 200 | sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" 201 | url: "https://pub.dev" 202 | source: hosted 203 | version: "1.17.0" 204 | mime: 205 | dependency: transitive 206 | description: 207 | name: mime 208 | sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" 209 | url: "https://pub.dev" 210 | source: hosted 211 | version: "2.0.0" 212 | mocktail: 213 | dependency: "direct dev" 214 | description: 215 | name: mocktail 216 | sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8" 217 | url: "https://pub.dev" 218 | source: hosted 219 | version: "1.0.4" 220 | mustache_template: 221 | dependency: "direct main" 222 | description: 223 | name: mustache_template 224 | sha256: a46e26f91445bfb0b60519be280555b06792460b27b19e2b19ad5b9740df5d1c 225 | url: "https://pub.dev" 226 | source: hosted 227 | version: "2.0.0" 228 | node_preamble: 229 | dependency: transitive 230 | description: 231 | name: node_preamble 232 | sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" 233 | url: "https://pub.dev" 234 | source: hosted 235 | version: "2.0.2" 236 | package_config: 237 | dependency: transitive 238 | description: 239 | name: package_config 240 | sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc 241 | url: "https://pub.dev" 242 | source: hosted 243 | version: "2.2.0" 244 | path: 245 | dependency: "direct main" 246 | description: 247 | name: path 248 | sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" 249 | url: "https://pub.dev" 250 | source: hosted 251 | version: "1.9.1" 252 | pool: 253 | dependency: transitive 254 | description: 255 | name: pool 256 | sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" 257 | url: "https://pub.dev" 258 | source: hosted 259 | version: "1.5.1" 260 | pub_semver: 261 | dependency: transitive 262 | description: 263 | name: pub_semver 264 | sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" 265 | url: "https://pub.dev" 266 | source: hosted 267 | version: "2.2.0" 268 | scoped_deps: 269 | dependency: "direct main" 270 | description: 271 | name: scoped_deps 272 | sha256: bc54cece4fed785157dc53b7554d31107f574897f4b2d1196db905a38c084e31 273 | url: "https://pub.dev" 274 | source: hosted 275 | version: "0.1.0+2" 276 | shelf: 277 | dependency: transitive 278 | description: 279 | name: shelf 280 | sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 281 | url: "https://pub.dev" 282 | source: hosted 283 | version: "1.4.2" 284 | shelf_packages_handler: 285 | dependency: transitive 286 | description: 287 | name: shelf_packages_handler 288 | sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" 289 | url: "https://pub.dev" 290 | source: hosted 291 | version: "3.0.2" 292 | shelf_static: 293 | dependency: transitive 294 | description: 295 | name: shelf_static 296 | sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 297 | url: "https://pub.dev" 298 | source: hosted 299 | version: "1.1.3" 300 | shelf_web_socket: 301 | dependency: transitive 302 | description: 303 | name: shelf_web_socket 304 | sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" 305 | url: "https://pub.dev" 306 | source: hosted 307 | version: "3.0.0" 308 | source_map_stack_trace: 309 | dependency: transitive 310 | description: 311 | name: source_map_stack_trace 312 | sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b 313 | url: "https://pub.dev" 314 | source: hosted 315 | version: "2.1.2" 316 | source_maps: 317 | dependency: transitive 318 | description: 319 | name: source_maps 320 | sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" 321 | url: "https://pub.dev" 322 | source: hosted 323 | version: "0.10.13" 324 | source_span: 325 | dependency: transitive 326 | description: 327 | name: source_span 328 | sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" 329 | url: "https://pub.dev" 330 | source: hosted 331 | version: "1.10.1" 332 | stack_trace: 333 | dependency: transitive 334 | description: 335 | name: stack_trace 336 | sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" 337 | url: "https://pub.dev" 338 | source: hosted 339 | version: "1.12.1" 340 | stream_channel: 341 | dependency: transitive 342 | description: 343 | name: stream_channel 344 | sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" 345 | url: "https://pub.dev" 346 | source: hosted 347 | version: "2.1.4" 348 | string_scanner: 349 | dependency: transitive 350 | description: 351 | name: string_scanner 352 | sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" 353 | url: "https://pub.dev" 354 | source: hosted 355 | version: "1.4.1" 356 | term_glyph: 357 | dependency: transitive 358 | description: 359 | name: term_glyph 360 | sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" 361 | url: "https://pub.dev" 362 | source: hosted 363 | version: "1.2.2" 364 | test: 365 | dependency: "direct dev" 366 | description: 367 | name: test 368 | sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" 369 | url: "https://pub.dev" 370 | source: hosted 371 | version: "1.26.3" 372 | test_api: 373 | dependency: transitive 374 | description: 375 | name: test_api 376 | sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 377 | url: "https://pub.dev" 378 | source: hosted 379 | version: "0.7.7" 380 | test_core: 381 | dependency: transitive 382 | description: 383 | name: test_core 384 | sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" 385 | url: "https://pub.dev" 386 | source: hosted 387 | version: "0.6.12" 388 | typed_data: 389 | dependency: transitive 390 | description: 391 | name: typed_data 392 | sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 393 | url: "https://pub.dev" 394 | source: hosted 395 | version: "1.4.0" 396 | version: 397 | dependency: "direct main" 398 | description: 399 | name: version 400 | sha256: "3d4140128e6ea10d83da32fef2fa4003fccbf6852217bb854845802f04191f94" 401 | url: "https://pub.dev" 402 | source: hosted 403 | version: "3.0.2" 404 | very_good_analysis: 405 | dependency: "direct dev" 406 | description: 407 | name: very_good_analysis 408 | sha256: e479fbc0941009262343db308133e121bf8660c2c81d48dd8e952df7b7e1e382 409 | url: "https://pub.dev" 410 | source: hosted 411 | version: "9.0.0" 412 | vm_service: 413 | dependency: transitive 414 | description: 415 | name: vm_service 416 | sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" 417 | url: "https://pub.dev" 418 | source: hosted 419 | version: "15.0.2" 420 | watcher: 421 | dependency: transitive 422 | description: 423 | name: watcher 424 | sha256: "5bf046f41320ac97a469d506261797f35254fa61c641741ef32dacda98b7d39c" 425 | url: "https://pub.dev" 426 | source: hosted 427 | version: "1.1.3" 428 | web: 429 | dependency: transitive 430 | description: 431 | name: web 432 | sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" 433 | url: "https://pub.dev" 434 | source: hosted 435 | version: "1.1.1" 436 | web_socket: 437 | dependency: transitive 438 | description: 439 | name: web_socket 440 | sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" 441 | url: "https://pub.dev" 442 | source: hosted 443 | version: "1.0.1" 444 | web_socket_channel: 445 | dependency: transitive 446 | description: 447 | name: web_socket_channel 448 | sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 449 | url: "https://pub.dev" 450 | source: hosted 451 | version: "3.0.3" 452 | webkit_inspection_protocol: 453 | dependency: transitive 454 | description: 455 | name: webkit_inspection_protocol 456 | sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" 457 | url: "https://pub.dev" 458 | source: hosted 459 | version: "1.2.1" 460 | win32: 461 | dependency: transitive 462 | description: 463 | name: win32 464 | sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03" 465 | url: "https://pub.dev" 466 | source: hosted 467 | version: "5.14.0" 468 | yaml: 469 | dependency: "direct main" 470 | description: 471 | name: yaml 472 | sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce 473 | url: "https://pub.dev" 474 | source: hosted 475 | version: "3.1.3" 476 | sdks: 477 | dart: ">=3.8.0 <4.0.0" 478 | -------------------------------------------------------------------------------- /test/render/render_tree_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:space_gen/src/quirks.dart'; 2 | import 'package:space_gen/src/render/render_tree.dart'; 3 | import 'package:space_gen/src/types.dart'; 4 | import 'package:test/test.dart'; 5 | 6 | void main() { 7 | group('variableSafeName', () { 8 | test('basic', () { 9 | const quirks = Quirks(); 10 | String makeSafe(String jsonName) => variableSafeName(quirks, jsonName); 11 | expect(makeSafe('a-b'), 'aB'); 12 | expect(makeSafe('a b'), 'aB'); 13 | expect(makeSafe('a b'), 'aB'); 14 | expect(makeSafe('aB'), 'aB'); 15 | }); 16 | 17 | test('enum names', () { 18 | const quirks = Quirks(); 19 | expect(quirks.screamingCapsEnums, isFalse); 20 | final names = RenderEnum.variableNamesFor(quirks, [ 21 | 'shortCamel', 22 | 'TallCamel', 23 | 'snake_case', 24 | 'skewer-case', 25 | 'ALL_CAPS', 26 | ]); 27 | expect(names, [ 28 | 'shortCamel', 29 | 'tallCamel', 30 | 'snakeCase', 31 | 'skewerCase', 32 | 'allCaps', 33 | ]); 34 | }); 35 | 36 | test('parameter names', () { 37 | const quirks = Quirks(); 38 | expect(quirks.screamingCapsEnums, isFalse); 39 | const common = CommonProperties.test( 40 | snakeName: 'a-b', 41 | pointer: JsonPointer.empty(), 42 | ); 43 | const parameter = RenderParameter( 44 | description: 'Foo', 45 | name: 'a-b', 46 | type: RenderUnknown(common: common), 47 | isRequired: true, 48 | isDeprecated: false, 49 | inLocation: ParameterLocation.query, 50 | ); 51 | expect(parameter.dartParameterName(quirks), 'aB'); 52 | }); 53 | }); 54 | 55 | group('createDocComment', () { 56 | test('basic', () { 57 | expect( 58 | createDocCommentFromParts(title: 'Foo', body: 'Bar', indent: 4), 59 | '/// Foo\n /// Bar\n ', 60 | ); 61 | expect( 62 | createDocCommentFromParts(title: 'Foo', body: 'Bar'), 63 | '/// Foo\n/// Bar\n', 64 | ); 65 | }); 66 | 67 | test('empty', () { 68 | expect(createDocCommentFromParts(indent: 4), isNull); 69 | }); 70 | 71 | test('title only', () { 72 | expect( 73 | createDocCommentFromParts(title: 'Foo\nBar', indent: 4), 74 | '/// Foo\n /// Bar\n ', 75 | ); 76 | }); 77 | 78 | test('body only', () { 79 | expect( 80 | createDocCommentFromParts(body: 'Bar\nBaz', indent: 4), 81 | '/// Bar\n /// Baz\n ', 82 | ); 83 | }); 84 | 85 | test('long line wrapping', () { 86 | const longLine = 87 | 'This is a very long line that should be wrapped to eighty ' 88 | 'characters by the doc comment generator.'; 89 | expect( 90 | createDocCommentFromParts(body: longLine, indent: 4), 91 | '/// This is a very long line that should be wrapped to eighty characters by\n' 92 | ' /// the doc comment generator.\n' 93 | ' ', 94 | ); 95 | }); 96 | }); 97 | 98 | group('indentWithTrailingNewline', () { 99 | test('basic', () { 100 | expect( 101 | indentWithTrailingNewline(['Foo', 'Bar'], indent: 4), 102 | 'Foo\n Bar\n ', 103 | ); 104 | }); 105 | 106 | test('empty', () { 107 | expect(indentWithTrailingNewline([], indent: 4), isNull); 108 | }); 109 | 110 | test('single line', () { 111 | expect(indentWithTrailingNewline(['Foo'], indent: 4), 'Foo\n '); 112 | }); 113 | 114 | test('trailing newline', () { 115 | // This doesn't seem right? 116 | expect(indentWithTrailingNewline(['Foo\n'], indent: 4), 'Foo\n\n '); 117 | }); 118 | }); 119 | 120 | group('equalsIgnoringName', () { 121 | test('RenderObject', () { 122 | const a = RenderObject( 123 | common: CommonProperties.test( 124 | snakeName: 'a', 125 | pointer: JsonPointer.empty(), 126 | description: 'Foo', 127 | ), 128 | properties: {}, 129 | ); 130 | expect(a.equalsIgnoringName(a), isTrue); 131 | 132 | const b = RenderObject( 133 | common: CommonProperties.test( 134 | snakeName: 'b', 135 | pointer: JsonPointer.empty(), 136 | description: 'Foo', 137 | ), 138 | properties: {}, 139 | ); 140 | expect(a.equalsIgnoringName(b), isTrue); 141 | 142 | const c = RenderObject( 143 | common: CommonProperties.test( 144 | snakeName: 'a', 145 | pointer: JsonPointer.empty(), 146 | description: 'Foo', 147 | ), 148 | properties: { 149 | 'a': RenderUnknown( 150 | common: CommonProperties.test( 151 | snakeName: 'a', 152 | pointer: JsonPointer.empty(), 153 | description: 'Foo', 154 | ), 155 | ), 156 | }, 157 | ); 158 | expect(a.equalsIgnoringName(c), isFalse); 159 | 160 | const d = RenderObject( 161 | common: CommonProperties.test( 162 | snakeName: 'a', 163 | pointer: JsonPointer.empty(), 164 | description: 'Foo', 165 | ), 166 | properties: { 167 | 'a': RenderPod( 168 | common: CommonProperties.test( 169 | snakeName: 'a', 170 | pointer: JsonPointer.empty(), 171 | description: 'Foo', 172 | ), 173 | type: PodType.boolean, 174 | ), 175 | }, 176 | ); 177 | expect(a.equalsIgnoringName(d), isFalse); 178 | 179 | const e = RenderObject( 180 | common: CommonProperties.test( 181 | snakeName: 'a', 182 | pointer: JsonPointer.empty(), 183 | description: 'Foo', 184 | ), 185 | properties: {}, 186 | additionalProperties: RenderPod( 187 | common: CommonProperties.test( 188 | snakeName: 'a', 189 | pointer: JsonPointer.empty(), 190 | description: 'Foo', 191 | ), 192 | type: PodType.boolean, 193 | ), 194 | ); 195 | expect(a.equalsIgnoringName(e), isFalse); 196 | 197 | const f = RenderObject( 198 | common: CommonProperties.test( 199 | snakeName: 'a', 200 | pointer: JsonPointer.empty(), 201 | description: 'Foo', 202 | ), 203 | properties: {}, 204 | requiredProperties: ['a'], 205 | ); 206 | expect(a.equalsIgnoringName(f), isFalse); 207 | 208 | const g = RenderObject( 209 | common: CommonProperties.test( 210 | snakeName: 'a', 211 | pointer: JsonPointer.empty(), 212 | description: 'Foo', 213 | ), 214 | properties: {}, 215 | additionalProperties: RenderPod( 216 | common: CommonProperties.test( 217 | snakeName: 'a', 218 | pointer: JsonPointer.empty(), 219 | description: 'Foo', 220 | ), 221 | type: PodType.dateTime, 222 | ), 223 | ); 224 | expect(e.equalsIgnoringName(g), isFalse); 225 | 226 | const h = RenderObject( 227 | common: CommonProperties.test( 228 | snakeName: 'a', 229 | pointer: JsonPointer.empty(), 230 | description: 'Foo', 231 | ), 232 | properties: { 233 | 'e': RenderPod( 234 | common: CommonProperties.test( 235 | snakeName: 'e', 236 | pointer: JsonPointer.empty(), 237 | description: 'Foo', 238 | ), 239 | type: PodType.boolean, 240 | ), 241 | }, 242 | ); 243 | expect(d.equalsIgnoringName(h), isFalse); 244 | }); 245 | 246 | test('RenderString', () { 247 | const a = RenderString( 248 | createsNewType: false, 249 | common: CommonProperties.test( 250 | snakeName: 'a', 251 | pointer: JsonPointer.empty(), 252 | description: 'Foo', 253 | ), 254 | defaultValue: 'foo', 255 | maxLength: 10, 256 | minLength: 1, 257 | pattern: 'foo', 258 | ); 259 | expect(a.equalsIgnoringName(a), isTrue); 260 | 261 | const b = RenderString( 262 | createsNewType: false, 263 | common: CommonProperties.test( 264 | snakeName: 'b', 265 | pointer: JsonPointer.empty(), 266 | description: 'Foo', 267 | ), 268 | defaultValue: 'foo', 269 | maxLength: null, 270 | minLength: 1, 271 | pattern: 'foo', 272 | ); 273 | expect(a.equalsIgnoringName(b), isFalse); 274 | 275 | const c = RenderString( 276 | createsNewType: false, 277 | common: CommonProperties.test( 278 | snakeName: 'a', 279 | pointer: JsonPointer.empty(), 280 | description: 'Foo', 281 | ), 282 | defaultValue: 'foo', 283 | maxLength: 10, 284 | minLength: null, 285 | pattern: 'foo', 286 | ); 287 | expect(a.equalsIgnoringName(c), isFalse); 288 | }); 289 | 290 | test('RenderInteger', () { 291 | const a = RenderInteger( 292 | createsNewType: false, 293 | common: CommonProperties.test( 294 | snakeName: 'a', 295 | pointer: JsonPointer.empty(), 296 | description: 'Foo', 297 | ), 298 | defaultValue: 1, 299 | maximum: 10, 300 | minimum: 1, 301 | exclusiveMaximum: 10, 302 | exclusiveMinimum: 1, 303 | multipleOf: 1, 304 | ); 305 | expect(a.equalsIgnoringName(a), isTrue); 306 | 307 | const b = RenderInteger( 308 | createsNewType: false, 309 | common: CommonProperties.test( 310 | snakeName: 'b', 311 | pointer: JsonPointer.empty(), 312 | description: 'Foo', 313 | ), 314 | defaultValue: 1, 315 | maximum: null, 316 | minimum: 1, 317 | exclusiveMaximum: null, 318 | exclusiveMinimum: null, 319 | multipleOf: null, 320 | ); 321 | expect(a.equalsIgnoringName(b), isFalse); 322 | 323 | const c = RenderInteger( 324 | createsNewType: false, 325 | common: CommonProperties.test( 326 | snakeName: 'a', 327 | pointer: JsonPointer.empty(), 328 | description: 'Foo', 329 | ), 330 | defaultValue: 1, 331 | maximum: 10, 332 | minimum: null, 333 | exclusiveMaximum: null, 334 | exclusiveMinimum: null, 335 | multipleOf: 1, 336 | ); 337 | expect(b.equalsIgnoringName(c), isFalse); 338 | }); 339 | 340 | test('RenderArray', () { 341 | const a = RenderArray( 342 | common: CommonProperties.test( 343 | snakeName: 'a', 344 | pointer: JsonPointer.empty(), 345 | description: 'Foo', 346 | ), 347 | items: RenderPod( 348 | common: CommonProperties.test( 349 | snakeName: 'a', 350 | pointer: JsonPointer.empty(), 351 | description: 'Foo', 352 | ), 353 | type: PodType.boolean, 354 | ), 355 | ); 356 | expect(a.equalsIgnoringName(a), isTrue); 357 | 358 | const b = RenderArray( 359 | common: CommonProperties.test( 360 | snakeName: 'b', 361 | pointer: JsonPointer.empty(), 362 | description: 'Foo', 363 | ), 364 | items: RenderPod( 365 | common: CommonProperties.test( 366 | snakeName: 'b', 367 | pointer: JsonPointer.empty(), 368 | description: 'Foo', 369 | ), 370 | type: PodType.boolean, 371 | ), 372 | ); 373 | expect(a.equalsIgnoringName(b), isTrue); 374 | 375 | const c = RenderArray( 376 | common: CommonProperties.test( 377 | snakeName: 'a', 378 | pointer: JsonPointer.empty(), 379 | description: 'Foo', 380 | ), 381 | items: RenderPod( 382 | common: CommonProperties.test( 383 | snakeName: 'a', 384 | pointer: JsonPointer.empty(), 385 | description: 'Foo', 386 | ), 387 | type: PodType.dateTime, 388 | ), 389 | ); 390 | expect(a.equalsIgnoringName(c), isFalse); 391 | }); 392 | 393 | test('RenderEnum', () { 394 | final a = RenderEnum( 395 | common: const CommonProperties.test( 396 | snakeName: 'a', 397 | pointer: JsonPointer.empty(), 398 | description: 'Foo', 399 | ), 400 | names: const ['a', 'b', 'c'], 401 | values: const ['a', 'b', 'c'], 402 | ); 403 | expect(a.equalsIgnoringName(a), isTrue); 404 | 405 | final b = RenderEnum( 406 | common: const CommonProperties.test( 407 | snakeName: 'b', 408 | pointer: JsonPointer.empty(), 409 | description: 'Foo', 410 | ), 411 | names: const ['a', 'b', 'c'], 412 | values: const ['a', 'b', 'c'], 413 | ); 414 | expect(a.equalsIgnoringName(b), isTrue); 415 | 416 | final c = RenderEnum( 417 | common: const CommonProperties.test( 418 | snakeName: 'a', 419 | pointer: JsonPointer.empty(), 420 | description: 'Foo', 421 | ), 422 | names: const ['a', 'b'], 423 | values: const ['a', 'b'], 424 | ); 425 | expect(a.equalsIgnoringName(c), isFalse); 426 | }); 427 | 428 | test('RenderOneOf', () { 429 | const a = RenderOneOf( 430 | common: CommonProperties.test( 431 | snakeName: 'a', 432 | pointer: JsonPointer.empty(), 433 | description: 'Foo', 434 | ), 435 | schemas: [ 436 | RenderPod( 437 | common: CommonProperties.test( 438 | snakeName: 'a', 439 | pointer: JsonPointer.empty(), 440 | description: 'Foo', 441 | ), 442 | type: PodType.boolean, 443 | ), 444 | ], 445 | ); 446 | expect(a.equalsIgnoringName(a), isTrue); 447 | 448 | const b = RenderOneOf( 449 | common: CommonProperties.test( 450 | snakeName: 'b', 451 | pointer: JsonPointer.empty(), 452 | description: 'Foo', 453 | ), 454 | schemas: [ 455 | RenderPod( 456 | common: CommonProperties.test( 457 | snakeName: 'b', 458 | pointer: JsonPointer.empty(), 459 | description: 'Foo', 460 | ), 461 | type: PodType.boolean, 462 | ), 463 | ], 464 | ); 465 | expect(a.equalsIgnoringName(b), isTrue); 466 | 467 | const c = RenderOneOf( 468 | common: CommonProperties.test( 469 | snakeName: 'a', 470 | pointer: JsonPointer.empty(), 471 | description: 'Foo', 472 | ), 473 | schemas: [ 474 | RenderPod( 475 | common: CommonProperties.test( 476 | snakeName: 'a', 477 | pointer: JsonPointer.empty(), 478 | description: 'Foo', 479 | ), 480 | type: PodType.boolean, 481 | ), 482 | RenderPod( 483 | common: CommonProperties.test( 484 | snakeName: 'b', 485 | pointer: JsonPointer.empty(), 486 | description: 'Foo', 487 | ), 488 | type: PodType.boolean, 489 | ), 490 | ], 491 | ); 492 | expect(a.equalsIgnoringName(c), isFalse); 493 | }); 494 | }); 495 | 496 | group('additionalImports', () { 497 | test('basic', () { 498 | expect( 499 | const RenderObject( 500 | common: CommonProperties.test( 501 | snakeName: 'a', 502 | pointer: JsonPointer.empty(), 503 | description: 'Foo', 504 | ), 505 | properties: {}, 506 | ).additionalImports, 507 | isEmpty, 508 | ); 509 | }); 510 | 511 | test('deprecated', () { 512 | expect( 513 | const RenderObject( 514 | common: CommonProperties.test( 515 | snakeName: 'a', 516 | pointer: JsonPointer.empty(), 517 | description: 'Foo', 518 | ), 519 | properties: { 520 | 'a': RenderString( 521 | createsNewType: false, 522 | common: CommonProperties.test( 523 | snakeName: 'a', 524 | pointer: JsonPointer.empty(), 525 | description: 'Foo', 526 | isDeprecated: true, 527 | ), 528 | defaultValue: 'foo', 529 | maxLength: null, 530 | minLength: null, 531 | pattern: null, 532 | ), 533 | }, 534 | ).additionalImports, 535 | equals([const Import('package:meta/meta.dart')]), 536 | ); 537 | }); 538 | 539 | test('uriTemplate', () { 540 | expect( 541 | const RenderPod( 542 | common: CommonProperties.test( 543 | snakeName: 'a', 544 | pointer: JsonPointer.empty(), 545 | description: 'Foo', 546 | ), 547 | type: PodType.uriTemplate, 548 | ).additionalImports, 549 | equals([const Import('package:uri/uri.dart')]), 550 | ); 551 | }); 552 | }); 553 | } 554 | -------------------------------------------------------------------------------- /lib/src/render/file_renderer.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:collection/collection.dart'; 4 | import 'package:file/file.dart'; 5 | import 'package:meta/meta.dart'; 6 | import 'package:path/path.dart' as p; 7 | import 'package:space_gen/src/logger.dart'; 8 | import 'package:space_gen/src/quirks.dart'; 9 | import 'package:space_gen/src/render/render_tree.dart'; 10 | import 'package:space_gen/src/render/schema_renderer.dart'; 11 | import 'package:space_gen/src/render/templates.dart'; 12 | import 'package:space_gen/src/render/tree_visitor.dart'; 13 | import 'package:space_gen/src/string.dart'; 14 | 15 | typedef RunProcess = 16 | ProcessResult Function( 17 | String executable, 18 | List arguments, { 19 | String? workingDirectory, 20 | }); 21 | 22 | class _ModelCollector extends RenderTreeVisitor { 23 | final Set schemas = {}; 24 | 25 | @override 26 | void visitSchema(RenderSchema schema) { 27 | schemas.add(schema); 28 | } 29 | } 30 | 31 | Set collectAllSchemas(RenderSpec spec) { 32 | final collector = _ModelCollector(); 33 | RenderTreeWalker(visitor: collector).walkRoot(spec); 34 | return collector.schemas; 35 | } 36 | 37 | Set collectSchemasUnderApi(Api api) { 38 | final collector = _ModelCollector(); 39 | RenderTreeWalker(visitor: collector).walkApi(api); 40 | return collector.schemas; 41 | } 42 | 43 | Set collectSchemasUnderSchema(RenderSchema schema) { 44 | final collector = _ModelCollector(); 45 | RenderTreeWalker(visitor: collector).walkSchema(schema); 46 | return collector.schemas; 47 | } 48 | 49 | @visibleForTesting 50 | void logNameCollisions(Iterable schemas) { 51 | // List schemas with name collisions but different pointers. 52 | final nameCollisions = schemas.groupListsBy((s) => s.snakeName); 53 | for (final entry in nameCollisions.entries) { 54 | if (entry.value.length > 1) { 55 | logger.warn( 56 | 'Schema ${entry.key} has ${entry.value.length} name collisions', 57 | ); 58 | for (final schema in entry.value) { 59 | logger.info('${schema.pointer}'); 60 | } 61 | } 62 | } 63 | } 64 | 65 | @visibleForTesting 66 | String applyMandatoryReplacements( 67 | String template, 68 | Map replacements, 69 | ) { 70 | var output = template; 71 | for (final replacement in replacements.entries) { 72 | final before = output; 73 | output = output.replaceAll(replacement.key, replacement.value); 74 | // Each replacement must be used, at least once or we fail. 75 | if (output == before) { 76 | throw Exception('Replacement ${replacement.key} not found'); 77 | } 78 | } 79 | return output; 80 | } 81 | 82 | class SpellChecker { 83 | SpellChecker({RunProcess? runProcess}) 84 | : runProcess = runProcess ?? Process.runSync; 85 | 86 | /// The function to run a process. Allows for mocking in tests. 87 | final RunProcess runProcess; 88 | 89 | List filesToCheck(Directory dir) { 90 | final extensions = {'.dart', '.md', '.yaml'}; 91 | return dir 92 | .listSync(recursive: true) 93 | .whereType() 94 | .where((f) => extensions.contains(p.extension(f.path))) 95 | .map((f) => f.path) 96 | .toList(); 97 | } 98 | 99 | List collectMisspellings(Directory dir) { 100 | // cspell seems to add an F/A prefix to the misspellings if we don't 101 | // pass explicit file paths, unclear why. 102 | final result = runProcess('cspell', [ 103 | '--no-summary', 104 | '--words-only', 105 | '--unique', 106 | '--quiet', 107 | '--no-color', 108 | '--no-progress', 109 | ...filesToCheck(dir), 110 | ]); 111 | // Lowercase, unique and sort the misspellings. 112 | return (result.stdout as String) 113 | .trim() 114 | .split('\n') 115 | .map((w) => w.toLowerCase()) 116 | .toSet() 117 | .sorted(); 118 | } 119 | } 120 | 121 | class Formatter { 122 | Formatter({RunProcess? runProcess}) 123 | : runProcess = runProcess ?? Process.runSync; 124 | 125 | /// The function to run a process. Allows for mocking in tests. 126 | final RunProcess runProcess; 127 | 128 | /// Run a dart command. 129 | void _runDart(List args, {required String workingDirectory}) { 130 | final command = 'dart ${args.join(' ')}'; 131 | logger 132 | ..info('Running $command') 133 | ..detail('$command in $workingDirectory'); 134 | final stopwatch = Stopwatch()..start(); 135 | final result = runProcess( 136 | Platform.executable, 137 | args, 138 | workingDirectory: workingDirectory, 139 | ); 140 | if (result.exitCode != 0) { 141 | logger.info(result.stderr as String); 142 | throw Exception('Failed to run dart ${args.join(' ')}'); 143 | } 144 | final ms = stopwatch.elapsed.inMilliseconds; 145 | logger 146 | ..detail(result.stdout as String) 147 | ..info('$command took $ms ms'); 148 | } 149 | 150 | void formatAndFix({required String pkgDir}) { 151 | // Consider running pub upgrade here to ensure packages are up to date. 152 | // Might need to make offline configurable? 153 | _runDart(['pub', 'get', '--offline'], workingDirectory: pkgDir); 154 | // Run format first to add missing commas. 155 | _runDart(['format', '.'], workingDirectory: pkgDir); 156 | // Then run fix to clean up various other things. 157 | _runDart(['fix', '.', '--apply'], workingDirectory: pkgDir); 158 | // Run format again to fix wrapping of lines. 159 | _runDart(['format', '.'], workingDirectory: pkgDir); 160 | } 161 | } 162 | 163 | class FileWriter { 164 | FileWriter({required this.outDir}) : fs = outDir.fileSystem; 165 | 166 | /// The output directory. 167 | final Directory outDir; 168 | 169 | /// The file system where the rendered files will go. 170 | final FileSystem fs; 171 | 172 | final Set _writtenFiles = {}; 173 | 174 | /// Ensure a file exists. 175 | File _ensureFile(String path) { 176 | final file = fs.file(p.join(outDir.path, path)); 177 | file.parent.createSync(recursive: true); 178 | return file; 179 | } 180 | 181 | /// Write a file. 182 | void writeFile({required String path, required String content}) { 183 | if (_writtenFiles.contains(path)) { 184 | throw Exception('File $path already written'); 185 | } 186 | _writtenFiles.add(path); 187 | _ensureFile(path).writeAsStringSync(content); 188 | } 189 | 190 | /// Create a directory. 191 | void createOutDir() { 192 | fs.directory(outDir.path).createSync(recursive: true); 193 | } 194 | } 195 | 196 | /// Responsible for determining the layout of the files and rendering the 197 | /// for the directory structure of the rendered spec. 198 | /// 199 | /// This FileRenderer uses a directory structure that is similar to the 200 | /// OpenAPI generator. Eventually we should make this an interface to allow 201 | /// rendering to other directory structures. 202 | class FileRenderer { 203 | FileRenderer({ 204 | required this.packageName, 205 | required this.templates, 206 | required this.schemaRenderer, 207 | required this.formatter, 208 | required this.fileWriter, 209 | required this.spellChecker, 210 | }); 211 | 212 | /// The package name this spec is being rendered into. 213 | final String packageName; 214 | 215 | /// The file writer to use for writing the files. 216 | final FileWriter fileWriter; 217 | 218 | /// The provider of templates. Could be different from the one used by 219 | /// the schema renderer, so we hold our own. 220 | final TemplateProvider templates; 221 | 222 | /// The formatter to use for formatting the files. 223 | final Formatter formatter; 224 | 225 | /// The spell checker to use for checking for misspellings. 226 | final SpellChecker spellChecker; 227 | 228 | /// The renderer for schemas and APIs. 229 | final SchemaRenderer schemaRenderer; 230 | 231 | /// The quirks to use for rendering. 232 | Quirks get quirks => schemaRenderer.quirks; 233 | 234 | /// The path to the api file. 235 | static String apiFilePath(Api api) { 236 | // openapi generator does not use /src/ in the path. 237 | return 'lib/api/${api.fileName}.dart'; 238 | } 239 | 240 | static String apiPackagePath(Api api) { 241 | return 'api/${api.fileName}.dart'; 242 | } 243 | 244 | static String modelFilePath(RenderSchema schema) { 245 | // openapi generator does not use /src/ in the path. 246 | return 'lib/model/${schema.snakeName}.dart'; 247 | } 248 | 249 | static String modelPackagePath(RenderSchema schema) { 250 | return 'model/${schema.snakeName}.dart'; 251 | } 252 | 253 | String modelPackageImport(FileRenderer context, RenderSchema schema) { 254 | return 'package:${context.packageName}/model/${schema.snakeName}.dart'; 255 | } 256 | 257 | // String packageImport(_Context context) { 258 | // final name = p.basenameWithoutExtension(ref!); 259 | // final snakeName = snakeFromCamel(name); 260 | // return 'package:${context.packageName}/model/$snakeName.dart'; 261 | // } 262 | 263 | /// Render a template. 264 | void _renderTemplate({ 265 | required String template, 266 | required String outPath, 267 | Map context = const {}, 268 | }) { 269 | final output = templates.loadTemplate(template).renderString(context); 270 | fileWriter.writeFile(path: outPath, content: output); 271 | } 272 | 273 | /// Render the package directory including 274 | /// pubspec, analysis_options, and gitignore. 275 | void _renderDirectory() { 276 | fileWriter.createOutDir(); 277 | _renderTemplate( 278 | template: 'pubspec', 279 | outPath: 'pubspec.yaml', 280 | context: {'packageName': packageName}, 281 | ); 282 | _renderTemplate( 283 | template: 'analysis_options', 284 | outPath: 'analysis_options.yaml', 285 | context: { 286 | 'mutableModels': quirks.mutableModels, 287 | 'screamingCapsEnums': quirks.screamingCapsEnums, 288 | }, 289 | ); 290 | _renderTemplate(template: 'gitignore', outPath: '.gitignore'); 291 | } 292 | 293 | void _renderDartFile({ 294 | required String name, 295 | required String outPath, 296 | Map replacements = const {}, 297 | }) { 298 | final output = templates.loadDartTemplate(name); 299 | final content = applyMandatoryReplacements(output, replacements); 300 | fileWriter.writeFile(path: outPath, content: content); 301 | } 302 | 303 | /// Render the api client. 304 | void _renderApiClient(RenderSpec spec) { 305 | _renderDartFile(name: 'api_exception', outPath: 'lib/api_exception.dart'); 306 | _renderDartFile(name: 'auth', outPath: 'lib/auth.dart'); 307 | _renderDartFile( 308 | name: 'api_client', 309 | outPath: 'lib/api_client.dart', 310 | replacements: { 311 | 'package:space_gen/templates': 'package:$packageName', 312 | 'TEMPLATE_BASE_URI': spec.serverUrl.toString(), 313 | }, 314 | ); 315 | _renderTemplate( 316 | template: 'model_helpers', 317 | outPath: 'lib/model_helpers.dart', 318 | ); 319 | } 320 | 321 | /// Render the public API file. 322 | void _renderPublicApi(Iterable apis, Iterable schemas) { 323 | final paths = { 324 | ...apis.map(apiPackagePath), 325 | ...schemas.map(modelPackagePath), 326 | 'api_client.dart', 327 | 'api_exception.dart', 328 | }; 329 | final exports = paths 330 | .map((path) => 'package:$packageName/$path') 331 | .sorted() 332 | .toList(); 333 | _renderTemplate( 334 | template: 'public_api', 335 | outPath: 'lib/api.dart', 336 | context: {'imports': [], 'exports': exports}, 337 | ); 338 | } 339 | 340 | void _renderCspellConfig(List misspellings) { 341 | _renderTemplate( 342 | template: 'cspell.config', 343 | outPath: 'cspell.config.yaml', 344 | context: { 345 | 'hasMisspellings': misspellings.isNotEmpty, 346 | 'misspellings': misspellings, 347 | }, 348 | ); 349 | } 350 | 351 | bool rendersToSeparateFile(RenderSchema schema) => schema.createsNewType; 352 | 353 | @visibleForTesting 354 | Iterable importsForApi(Api api) { 355 | // TODO(eseidel): Make type imports dynamic based on used schemas. 356 | final imports = { 357 | const Import('dart:async'), 358 | const Import('dart:convert'), // jsonDecode, for decoding response body. 359 | const Import('dart:io'), 360 | Import('package:$packageName/api_client.dart'), 361 | Import('package:$packageName/api_exception.dart'), 362 | const Import('package:http/http.dart', asName: 'http'), 363 | }; 364 | 365 | final apiSchemas = collectSchemasUnderApi(api); 366 | final inlineSchemas = apiSchemas.where((s) => !rendersToSeparateFile(s)); 367 | final importedSchemas = apiSchemas.where(rendersToSeparateFile); 368 | final apiImports = importedSchemas 369 | .map((s) => Import(modelPackageImport(this, s))) 370 | .toList(); 371 | imports.addAll({ 372 | ...inlineSchemas.expand((s) => s.additionalImports), 373 | ...apiImports, 374 | }); 375 | return imports; 376 | } 377 | 378 | List _renderApis(List apis) { 379 | final rendered = []; 380 | for (final api in apis) { 381 | final content = schemaRenderer.renderApi(api); 382 | final imports = importsForApi(api); 383 | final importsContext = imports 384 | .sortedBy((i) => i.path) 385 | .map((i) => i.toTemplateContext()) 386 | .toList(); 387 | final outPath = apiFilePath(api); 388 | _renderTemplate( 389 | template: 'add_imports', 390 | outPath: outPath, 391 | context: {'imports': importsContext, 'content': content}, 392 | ); 393 | rendered.add(api); 394 | } 395 | return rendered; 396 | } 397 | 398 | void _renderClient(List apis, {required String specName}) { 399 | final apiContexts = apis.map((a) { 400 | return {'apiClassName': a.className, 'apiName': a.clientVariableName}; 401 | }).toList(); 402 | _renderTemplate( 403 | template: 'client', 404 | outPath: 'lib/client.dart', 405 | context: { 406 | 'apis': apiContexts, 407 | 'packageName': packageName, 408 | 'clientClassName': toUpperCamelCase(specName), 409 | }, 410 | ); 411 | } 412 | 413 | @visibleForTesting 414 | Iterable importsForModel(RenderSchema schema) { 415 | final referencedSchemas = collectSchemasUnderSchema(schema); 416 | final localSchemas = referencedSchemas.where( 417 | (s) => !rendersToSeparateFile(s), 418 | ); 419 | final importedSchemas = referencedSchemas 420 | .where(rendersToSeparateFile) 421 | .toSet(); 422 | final referencedImports = importedSchemas 423 | .map((s) => Import(modelPackageImport(this, s))) 424 | .toList(); 425 | 426 | final imports = { 427 | const Import('dart:convert'), 428 | const Import('dart:io'), 429 | const Import('package:meta/meta.dart'), 430 | Import('package:$packageName/model_helpers.dart'), 431 | ...schema.additionalImports, 432 | ...localSchemas.expand((s) => s.additionalImports), 433 | ...referencedImports, 434 | }; 435 | return imports; 436 | } 437 | 438 | void renderModels(Iterable schemas) { 439 | for (final schema in schemas) { 440 | final content = schemaRenderer.renderSchema(schema); 441 | final imports = importsForModel(schema); 442 | final importsContext = imports 443 | .sortedBy((i) => i.path) 444 | .map((i) => i.toTemplateContext()) 445 | .toList(); 446 | 447 | final outPath = modelFilePath(schema); 448 | _renderTemplate( 449 | template: 'add_imports', 450 | outPath: outPath, 451 | context: {'imports': importsContext, 'content': content}, 452 | ); 453 | } 454 | } 455 | 456 | /// Render the entire spec. 457 | void render(RenderSpec spec, {bool clearDirectory = true}) { 458 | if (clearDirectory) { 459 | // Only delete the directories we make to handle the case of changing 460 | // directory structure or adding/removing files. 461 | // All other files we make can be overwritten. 462 | final dirs = {p.join('lib', 'api'), p.join('lib', 'model')}; 463 | for (final dirName in dirs) { 464 | final path = p.join(fileWriter.outDir.path, dirName); 465 | final dir = fileWriter.fs.directory(path); 466 | if (dir.existsSync()) { 467 | dir.deleteSync(recursive: true); 468 | } 469 | } 470 | } 471 | // Collect all the Apis and Model Schemas. 472 | // Do we walk through each endpoint and ask which class to put it on? 473 | // Do we then walk through each class and ask what file to put it in? 474 | // Then we walk through all model objects and ask what file to put them in? 475 | // And then for each rendered we collect any imports, by asking for the 476 | // file path for each referenced schema? 477 | // Set up the package directory. 478 | _renderDirectory(); 479 | // Render the apis (endpoint groups). 480 | final renderedApis = _renderApis(spec.apis); 481 | 482 | final schemas = collectAllSchemas(spec).where(rendersToSeparateFile); 483 | logNameCollisions(schemas); 484 | 485 | // Render the models (schemas). 486 | renderModels(schemas); 487 | // Render the api client. 488 | _renderApiClient(spec); 489 | // This is a bit of hack, but seems to work with the specs I've tested. 490 | // Probably ClientName should be a parameter to the render function. 491 | final specName = spec.title.split(' ').firstOrNull ?? ''; 492 | _renderClient(renderedApis, specName: specName); 493 | // Render the combined api.dart exporting all rendered schemas. 494 | _renderPublicApi(spec.apis, schemas); 495 | formatter.formatAndFix(pkgDir: fileWriter.outDir.path); 496 | 497 | final misspellings = spellChecker.collectMisspellings(fileWriter.outDir); 498 | _renderCspellConfig(misspellings); 499 | } 500 | } 501 | -------------------------------------------------------------------------------- /lib/src/parse/spec.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | import 'package:meta/meta.dart'; 3 | import 'package:space_gen/src/types.dart'; 4 | import 'package:version/version.dart'; 5 | 6 | export 'package:space_gen/src/types.dart'; 7 | export 'package:version/version.dart'; 8 | 9 | /// A typedef representing a json object. 10 | typedef Json = Map; 11 | 12 | abstract class HasPointer { 13 | JsonPointer get pointer; 14 | } 15 | 16 | /// A parameter is a parameter to an endpoint. 17 | /// https://spec.openapis.org/oas/v3.0.0#parameter-object 18 | @immutable 19 | class Parameter extends Equatable implements HasPointer, Parseable { 20 | /// Create a new parameter. 21 | const Parameter({ 22 | required this.name, 23 | required this.description, 24 | required this.type, 25 | required this.isRequired, 26 | required this.inLocation, 27 | required this.isDeprecated, 28 | required this.pointer, 29 | }); 30 | 31 | /// The name of the parameter. 32 | final String name; 33 | 34 | /// The description of the parameter. 35 | final String? description; 36 | 37 | /// Whether the parameter is required. 38 | final bool isRequired; 39 | 40 | /// Whether the parameter is deprecated. 41 | final bool isDeprecated; 42 | 43 | /// The "in" of the parameter. 44 | /// e.g. query, header, path, cookie. 45 | final ParameterLocation inLocation; 46 | 47 | /// The type of the parameter. 48 | final SchemaRef type; 49 | 50 | /// Where this parameter is located in the spec. 51 | @override 52 | final JsonPointer pointer; 53 | 54 | @override 55 | List get props => [ 56 | name, 57 | description, 58 | isRequired, 59 | inLocation, 60 | type, 61 | pointer, 62 | ]; 63 | } 64 | 65 | @immutable 66 | class Header extends Equatable implements HasPointer, Parseable { 67 | const Header({ 68 | required this.description, 69 | required this.schema, 70 | required this.pointer, 71 | }); 72 | 73 | /// The description of the header. 74 | final String? description; 75 | 76 | /// The type of the header. 77 | final SchemaRef? schema; 78 | 79 | /// Where this header is located in the spec. 80 | @override 81 | final JsonPointer pointer; 82 | 83 | @override 84 | List get props => [description, schema, pointer]; 85 | } 86 | 87 | abstract class Parseable {} 88 | 89 | @immutable 90 | class Ref extends Equatable { 91 | const Ref(this.uri); 92 | final Uri uri; 93 | Type get type => T; 94 | 95 | @override 96 | List get props => [uri]; 97 | } 98 | 99 | /// An object which either holds a schema or a reference to a schema. 100 | /// https://spec.openapis.org/oas/v3.0.0#schemaObject 101 | @immutable 102 | class RefOr extends Equatable { 103 | RefOr.ref(String stringRef, this.pointer) 104 | : object = null, 105 | ref = Ref(Uri.parse(stringRef)); 106 | const RefOr.object(this.object, this.pointer) : ref = null; 107 | 108 | final Ref? ref; 109 | final T? object; 110 | 111 | final JsonPointer pointer; 112 | 113 | @override 114 | List get props => [ref, object]; 115 | } 116 | 117 | typedef SchemaRef = RefOr; 118 | 119 | sealed class Schema extends Equatable implements HasPointer, Parseable { 120 | const Schema({required this.common}); 121 | 122 | final CommonProperties common; 123 | 124 | /// Where this schema is located in the spec. 125 | @override 126 | JsonPointer get pointer => common.pointer; 127 | 128 | /// The snake name of this schema. 129 | String get snakeName => common.snakeName; 130 | 131 | @override 132 | List get props => [common]; 133 | } 134 | 135 | /// A schema which is a POD (plain old data) type. 136 | class SchemaPod extends Schema { 137 | const SchemaPod({ 138 | required super.common, 139 | required this.type, 140 | required this.defaultValue, 141 | }); 142 | 143 | /// The type of data. 144 | final PodType type; 145 | 146 | /// The default value of the data. 147 | // TODO(eseidel): This should be typed. 148 | final dynamic defaultValue; 149 | 150 | @override 151 | List get props => [super.props, type, defaultValue]; 152 | } 153 | 154 | class SchemaString extends Schema { 155 | const SchemaString({ 156 | required super.common, 157 | required this.defaultValue, 158 | required this.maxLength, 159 | required this.minLength, 160 | required this.pattern, 161 | }); 162 | 163 | final String? defaultValue; 164 | final int? maxLength; 165 | final int? minLength; 166 | final String? pattern; 167 | 168 | @override 169 | List get props => [ 170 | super.props, 171 | defaultValue, 172 | maxLength, 173 | minLength, 174 | pattern, 175 | ]; 176 | } 177 | 178 | abstract class SchemaNumeric extends Schema { 179 | const SchemaNumeric({ 180 | required super.common, 181 | required this.defaultValue, 182 | required this.minimum, 183 | required this.maximum, 184 | required this.exclusiveMinimum, 185 | required this.exclusiveMaximum, 186 | required this.multipleOf, 187 | }); 188 | 189 | final T? defaultValue; 190 | final T? minimum; 191 | final T? maximum; 192 | final T? exclusiveMinimum; 193 | final T? exclusiveMaximum; 194 | final T? multipleOf; 195 | } 196 | 197 | class SchemaInteger extends SchemaNumeric { 198 | const SchemaInteger({ 199 | required super.common, 200 | required super.defaultValue, 201 | required super.minimum, 202 | required super.maximum, 203 | required super.exclusiveMinimum, 204 | required super.exclusiveMaximum, 205 | required super.multipleOf, 206 | }); 207 | } 208 | 209 | class SchemaNumber extends SchemaNumeric { 210 | const SchemaNumber({ 211 | required super.common, 212 | required super.defaultValue, 213 | required super.minimum, 214 | required super.maximum, 215 | required super.exclusiveMinimum, 216 | required super.exclusiveMaximum, 217 | required super.multipleOf, 218 | }); 219 | } 220 | 221 | abstract class SchemaObjectBase extends Schema { 222 | const SchemaObjectBase({required super.common}); 223 | } 224 | 225 | /// Map isn't a type in the spec, but rather inferred by having 226 | /// additionalProperties and no other properties. 227 | class SchemaMap extends Schema { 228 | const SchemaMap({required super.common, required this.valueSchema}); 229 | 230 | final SchemaRef valueSchema; 231 | 232 | @override 233 | List get props => [super.props, valueSchema]; 234 | } 235 | 236 | class SchemaBinary extends Schema { 237 | const SchemaBinary({required super.common}); 238 | } 239 | 240 | class SchemaEnum extends Schema { 241 | const SchemaEnum({ 242 | required super.common, 243 | required this.defaultValue, 244 | required this.enumValues, 245 | }); 246 | 247 | /// The default value of the enum. 248 | final String? defaultValue; 249 | 250 | // Only string enums are supported for now. 251 | final List enumValues; 252 | 253 | @override 254 | List get props => [super.props, defaultValue, enumValues]; 255 | } 256 | 257 | class SchemaNull extends Schema { 258 | const SchemaNull({required super.common}); 259 | } 260 | 261 | class SchemaArray extends Schema { 262 | const SchemaArray({ 263 | required super.common, 264 | required this.items, 265 | required this.defaultValue, 266 | required this.maxItems, 267 | required this.minItems, 268 | required this.uniqueItems, 269 | }); 270 | 271 | final SchemaRef items; 272 | 273 | final int? maxItems; // Non-negative. 274 | final int? minItems; // Non-negative. 275 | final bool uniqueItems; 276 | // final SchemaRef? contains; 277 | // final int? minContains; // Non-negative. 278 | // final int? maxContains; // Non-negative. 279 | 280 | final dynamic defaultValue; 281 | 282 | @override 283 | List get props => [ 284 | super.props, 285 | items, 286 | defaultValue, 287 | maxItems, 288 | minItems, 289 | uniqueItems, 290 | ]; 291 | } 292 | 293 | // Renders as dynamic. 294 | class SchemaUnknown extends Schema { 295 | const SchemaUnknown({required super.common}); 296 | } 297 | 298 | // Parses as a single, constant object, renders to json as {}. 299 | class SchemaEmptyObject extends Schema { 300 | const SchemaEmptyObject({required super.common}); 301 | } 302 | 303 | abstract class SchemaCombiner extends SchemaObjectBase { 304 | const SchemaCombiner({required super.common, required this.schemas}); 305 | 306 | final List schemas; 307 | 308 | @override 309 | List get props => [super.props, schemas]; 310 | } 311 | 312 | class SchemaAnyOf extends SchemaCombiner { 313 | const SchemaAnyOf({required super.common, required super.schemas}); 314 | } 315 | 316 | class SchemaAllOf extends SchemaCombiner { 317 | const SchemaAllOf({required super.common, required super.schemas}); 318 | } 319 | 320 | class SchemaOneOf extends SchemaCombiner { 321 | const SchemaOneOf({required super.common, required super.schemas}); 322 | } 323 | 324 | /// A schema is a json object that describes the shape of a json object. 325 | /// https://spec.openapis.org/oas/v3.0.0#schemaObject 326 | @immutable 327 | class SchemaObject extends SchemaObjectBase { 328 | /// Create a new schema. 329 | SchemaObject({ 330 | required super.common, 331 | required this.properties, 332 | required this.requiredProperties, 333 | required this.additionalProperties, 334 | required this.defaultValue, 335 | }) { 336 | if (snakeName.isEmpty) { 337 | throw ArgumentError.value( 338 | snakeName, 339 | 'snakeName', 340 | 'Schema name cannot be empty', 341 | ); 342 | } 343 | } 344 | 345 | /// The properties of this schema. 346 | final Map properties; 347 | 348 | /// The required properties of this schema. 349 | final List requiredProperties; 350 | 351 | /// The additional properties of this schema. 352 | /// Used for specifying T for Map\. 353 | final SchemaRef? additionalProperties; 354 | 355 | /// The default value of this schema. 356 | final dynamic defaultValue; 357 | 358 | @override 359 | List get props => [ 360 | super.props, 361 | properties, 362 | requiredProperties, 363 | additionalProperties, 364 | defaultValue, 365 | ]; 366 | 367 | @override 368 | String toString() { 369 | return 'Schema(name: $snakeName, pointer: $pointer, ' 370 | 'description: ${common.description})'; 371 | } 372 | } 373 | 374 | /// A media type is a mime type and a schema. 375 | /// https://spec.openapis.org/oas/v3.0.0#mediaTypeObject 376 | @immutable 377 | class MediaType extends Equatable { 378 | /// Create a new media type. 379 | const MediaType({required this.schema}); 380 | 381 | /// 3.0.1 seems to allow a ref in MediaType, but 3.1.0 does not. 382 | final SchemaRef schema; 383 | 384 | @override 385 | List get props => [schema]; 386 | } 387 | 388 | /// Request body is sorta a schema, but it's a bit different. 389 | /// https://spec.openapis.org/oas/v3.0.0#requestBodyObject 390 | /// Notably "required" is a boolean, not a list of strings. 391 | @immutable 392 | class RequestBody extends Equatable implements HasPointer, Parseable { 393 | const RequestBody({ 394 | required this.pointer, 395 | required this.description, 396 | required this.content, 397 | required this.isRequired, 398 | }); 399 | 400 | /// Where this request body is located in the spec. 401 | @override 402 | final JsonPointer pointer; 403 | 404 | /// The description of the request body. 405 | final String? description; 406 | 407 | /// The content of the request body. 408 | final Map content; 409 | 410 | /// Whether the request body is required. 411 | final bool isRequired; 412 | 413 | @override 414 | List get props => [pointer, description, content, isRequired]; 415 | } 416 | 417 | class Operation extends Equatable implements HasPointer { 418 | const Operation({ 419 | required this.pointer, 420 | required this.tags, 421 | required this.snakeName, 422 | required this.summary, 423 | required this.description, 424 | required this.responses, 425 | required this.parameters, 426 | required this.requestBody, 427 | required this.deprecated, 428 | required this.securityRequirements, 429 | }); 430 | 431 | /// Where this operation is located in the spec. 432 | @override 433 | final JsonPointer pointer; 434 | 435 | /// A list of tags for this operation. 436 | final List tags; 437 | 438 | /// The snake name of this endpoint (e.g. get_user) 439 | /// Typically the operationId, or the last path segment if not present. 440 | final String snakeName; 441 | 442 | /// The summary of this operation. 443 | final String? summary; 444 | 445 | /// The description of this operation. 446 | final String? description; 447 | 448 | /// The responses of this operation. 449 | final Responses responses; 450 | 451 | /// The parameters of this operation. 452 | final List> parameters; 453 | 454 | /// The request body of this operation. 455 | final RefOr? requestBody; 456 | 457 | /// Whether this operation is deprecated. 458 | final bool deprecated; 459 | 460 | /// The security requirements of this operation. 461 | final List securityRequirements; 462 | 463 | @override 464 | List get props => [ 465 | tags, 466 | snakeName, 467 | summary, 468 | description, 469 | responses, 470 | parameters, 471 | requestBody, 472 | deprecated, 473 | ]; 474 | } 475 | 476 | /// An endpoint is a path with a method. 477 | /// https://spec.openapis.org/oas/v3.0.0#path-item-object 478 | @immutable 479 | class PathItem extends Equatable implements HasPointer { 480 | /// Create a new endpoint. 481 | const PathItem({ 482 | required this.pointer, 483 | required this.path, 484 | required this.operations, 485 | required this.summary, 486 | required this.description, 487 | // required this.parameters, 488 | }); 489 | 490 | /// Where this path item is located in the spec. 491 | @override 492 | final JsonPointer pointer; 493 | 494 | /// The path of this endpoint (e.g. /my/user/{name}) 495 | final String path; 496 | 497 | /// The operations supported by this path. 498 | final Map operations; 499 | 500 | /// The default summary for all operations in this path. 501 | final String? summary; 502 | 503 | /// The default description for all operations in this path. 504 | final String? description; 505 | 506 | /// Parameters available to all operations in this path. 507 | // final List parameters; 508 | 509 | @override 510 | List get props => [ 511 | path, 512 | operations, 513 | summary, 514 | description, 515 | // parameters, 516 | ]; 517 | } 518 | 519 | /// A map of response codes to responses. 520 | /// https://spec.openapis.org/oas/v3.1.0#responses-object 521 | @immutable 522 | class Responses extends Equatable implements Parseable { 523 | /// Create a new responses object. 524 | const Responses({required this.responses}); 525 | 526 | /// The responses of this endpoint. 527 | final Map> responses; 528 | 529 | // default is not yet supported. 530 | 531 | /// Whether this endpoint has any responses. 532 | bool get isEmpty => responses.isEmpty; 533 | 534 | RefOr? operator [](int code) => responses[code]; 535 | 536 | @override 537 | List get props => [responses]; 538 | } 539 | 540 | /// A response from an endpoint. 541 | /// https://spec.openapis.org/oas/v3.1.0#response-object 542 | @immutable 543 | class Response extends Equatable implements HasPointer, Parseable { 544 | /// Create a new response. 545 | const Response({ 546 | required this.pointer, 547 | required this.description, 548 | this.content, 549 | this.headers, 550 | }); 551 | 552 | /// Where this response is located in the spec. 553 | @override 554 | final JsonPointer pointer; 555 | 556 | /// The description of this response. 557 | final String description; 558 | 559 | /// The content of this response. 560 | final Map? content; 561 | 562 | /// The possible headers for this response. 563 | final Map>? headers; 564 | 565 | @override 566 | List get props => [pointer, description, content, headers]; 567 | } 568 | 569 | @immutable 570 | class Components extends Equatable { 571 | const Components({ 572 | this.schemas = const {}, 573 | this.requestBodies = const {}, 574 | this.parameters = const {}, 575 | this.responses = const {}, 576 | this.headers = const {}, 577 | this.securitySchemes = const [], 578 | }); 579 | 580 | final Map schemas; 581 | final Map> parameters; 582 | 583 | final List securitySchemes; 584 | final Map> requestBodies; 585 | final Map> responses; 586 | final Map> headers; 587 | // final Map examples; 588 | // final Map links; 589 | // final Map callbacks; 590 | 591 | @override 592 | List get props => [schemas, requestBodies, parameters]; 593 | } 594 | 595 | @immutable 596 | class Info extends Equatable { 597 | const Info(this.title, this.version); 598 | final String title; 599 | final String version; 600 | @override 601 | List get props => [title, version]; 602 | } 603 | 604 | /// A map of paths to path items. 605 | /// https://spec.openapis.org/oas/v3.1.0#paths-object 606 | @immutable 607 | class Paths extends Equatable { 608 | const Paths({required this.paths}); 609 | 610 | final Map paths; 611 | 612 | Iterable get keys => paths.keys; 613 | PathItem operator [](String path) => paths[path]!; 614 | 615 | @override 616 | List get props => [paths]; 617 | } 618 | 619 | @immutable 620 | class Tag extends Equatable { 621 | const Tag({required this.name, required this.description}); 622 | 623 | final String name; 624 | final String? description; 625 | 626 | @override 627 | List get props => [name, description]; 628 | } 629 | 630 | /// Each requirement is a map of security scheme names to values. 631 | /// Values are lists of scope or roles, depending on the referenced scheme. 632 | /// At the parsing stage we just collect the requirements, at the resolve 633 | /// stage we'll validate that they reference valid security schemes, etc. 634 | class SecurityRequirement extends Equatable { 635 | const SecurityRequirement({required this.conditions, required this.pointer}); 636 | 637 | /// The conditions of the security requirement. 638 | /// Keys are security scheme names, and values are lists of scopes or roles. 639 | final Map> conditions; 640 | 641 | /// The pointer to the security requirement. 642 | final JsonPointer pointer; 643 | 644 | @override 645 | List get props => [conditions, pointer]; 646 | } 647 | 648 | /// The OpenAPI object. The root object of a spec. 649 | /// https://spec.openapis.org/oas/v3.1.0#openapi-object 650 | /// Objects in this library are not a one-to-one mapping with the spec, 651 | /// and may include extra information from parsing e.g. snakeCaseName. 652 | @immutable 653 | class OpenApi extends Equatable { 654 | const OpenApi({ 655 | required this.serverUrl, 656 | required this.version, 657 | required this.info, 658 | required this.paths, 659 | required this.components, 660 | required this.tags, 661 | required this.securityRequirements, 662 | }); 663 | 664 | /// The server url of the spec. 665 | final Uri serverUrl; 666 | 667 | /// The version of the spec. 668 | final Version version; 669 | 670 | /// The info of the spec. 671 | final Info info; 672 | 673 | /// The paths of the spec. 674 | final Paths paths; 675 | 676 | /// The components of the spec. 677 | final Components components; 678 | 679 | /// The security requirements applied to all operations within the spec. 680 | final List securityRequirements; 681 | 682 | /// The tags of the spec. 683 | final List tags; 684 | 685 | @override 686 | List get props => [ 687 | serverUrl, 688 | info, 689 | paths, 690 | components, 691 | securityRequirements, 692 | tags, 693 | ]; 694 | } 695 | --------------------------------------------------------------------------------