├── .pubignore ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.md │ └── bug_report.md ├── dependabot.yaml ├── workflows │ ├── publish.yml │ └── nenuphar_cli.yaml ├── PULL_REQUEST_TEMPLATE.md └── cspell.json ├── docs ├── google6d1b9ead5db42c24.html ├── pictures │ ├── logo.png │ ├── nenuphar_swagger.png │ └── dart_frog_logo.svg ├── development.md └── index.md ├── lib ├── src │ ├── version.dart │ ├── tooling.dart │ ├── commands │ │ ├── commands.dart │ │ ├── watch_command.dart │ │ ├── update_command.dart │ │ ├── init_command.dart │ │ └── gen_command.dart │ ├── extensions │ │ └── string_extension.dart │ ├── models │ │ ├── tag.dart │ │ ├── license.dart │ │ ├── media_type.dart │ │ ├── server.dart │ │ ├── header.dart │ │ ├── contact.dart │ │ ├── models.dart │ │ ├── external_documentation.dart │ │ ├── tag.g.dart │ │ ├── license.g.dart │ │ ├── request_body.dart │ │ ├── response_body.dart │ │ ├── header.g.dart │ │ ├── server.g.dart │ │ ├── paths.dart │ │ ├── media_type.g.dart │ │ ├── info.dart │ │ ├── openapi.dart │ │ ├── contact.g.dart │ │ ├── schema.dart │ │ ├── components.dart │ │ ├── external_documentation.g.dart │ │ ├── components.g.dart │ │ ├── parameter.dart │ │ ├── response_body.g.dart │ │ ├── request_body.g.dart │ │ ├── oauth_flow.g.dart │ │ ├── oauth_flows.dart │ │ ├── daemon_message.dart │ │ ├── oauth_flow.dart │ │ ├── info.g.dart │ │ ├── oauth_flows.g.dart │ │ ├── security_scheme.g.dart │ │ ├── daemon_message.g.dart │ │ ├── schema.g.dart │ │ ├── parameter.g.dart │ │ ├── daemon_event.dart │ │ ├── openapi.g.dart │ │ ├── paths.g.dart │ │ ├── method.g.dart │ │ ├── daemon_event.g.dart │ │ ├── method.dart │ │ └── security_scheme.dart │ ├── command_runner.dart │ └── helpers │ │ └── dart_frog_daemon_client.dart └── nenuphar_cli.dart ├── dart_test.yaml ├── example ├── analysis_options.yaml ├── routes │ ├── index.dart │ ├── todos │ │ ├── _middleware.dart │ │ ├── [id].dart │ │ └── index.dart │ └── _middleware.dart ├── .gitignore ├── components │ ├── todos.json │ └── _security.json ├── lib │ ├── services │ │ ├── auth_service.dart │ │ └── todo_service.dart │ └── models │ │ ├── todos.g.dart │ │ └── todos.dart ├── pubspec.yaml ├── README.md ├── test │ └── routes │ │ └── index_test.dart ├── public │ ├── index.html │ └── openapi.json └── nenuphar.json ├── analysis_options.yaml ├── test ├── ensure_build_test.dart └── src │ ├── models │ └── openapi_test.dart │ ├── extensions │ └── string_extension_test.dart │ ├── commands │ ├── init_command_test.dart │ ├── update_command_test.dart │ └── gen_command_test.dart │ └── command_runner_test.dart ├── .gitignore ├── bin └── nenuphar.dart ├── pubspec.yaml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── .devcontainer └── devcontainer.json ├── README.md └── CODE_OF_CONDUCT.md /.pubignore: -------------------------------------------------------------------------------- 1 | /docs/ -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/ config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /docs/google6d1b9ead5db42c24.html: -------------------------------------------------------------------------------- 1 | google-site-verification: google6d1b9ead5db42c24.html 2 | -------------------------------------------------------------------------------- /lib/src/version.dart: -------------------------------------------------------------------------------- 1 | // Generated code. Do not modify. 2 | const packageVersion = '0.2.2'; 3 | -------------------------------------------------------------------------------- /docs/pictures/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PiotrFLEURY/nenuphar_cli/HEAD/docs/pictures/logo.png -------------------------------------------------------------------------------- /lib/src/tooling.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | const jsonPrettyEncoder = JsonEncoder.withIndent(' '); 4 | -------------------------------------------------------------------------------- /docs/pictures/nenuphar_swagger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PiotrFLEURY/nenuphar_cli/HEAD/docs/pictures/nenuphar_swagger.png -------------------------------------------------------------------------------- /dart_test.yaml: -------------------------------------------------------------------------------- 1 | tags: 2 | version-verify: 3 | skip: "Should only be run during pull request. Verifies if version file is updated." -------------------------------------------------------------------------------- /lib/src/commands/commands.dart: -------------------------------------------------------------------------------- 1 | export 'gen_command.dart'; 2 | export 'init_command.dart'; 3 | export 'update_command.dart'; 4 | export 'watch_command.dart'; 5 | -------------------------------------------------------------------------------- /example/analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:very_good_analysis/analysis_options.5.0.0.yaml 2 | analyzer: 3 | exclude: 4 | - build/** 5 | linter: 6 | rules: 7 | file_names: false 8 | flutter_style_todos: false 9 | -------------------------------------------------------------------------------- /example/routes/index.dart: -------------------------------------------------------------------------------- 1 | import 'package:dart_frog/dart_frog.dart'; 2 | 3 | /// The / route is ignored by nenuphar 4 | Response onRequest(RequestContext context) { 5 | return Response(body: 'Welcome to Dart Frog!'); 6 | } 7 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:very_good_analysis/analysis_options.5.0.0.yaml 2 | 3 | analyzer: 4 | exclude: 5 | - '**/*.g.dart' 6 | 7 | 8 | linter: 9 | rules: 10 | public_member_api_docs: false 11 | 12 | -------------------------------------------------------------------------------- /test/ensure_build_test.dart: -------------------------------------------------------------------------------- 1 | @Tags(['version-verify']) 2 | library ensure_build_test; 3 | 4 | import 'package:build_verify/build_verify.dart'; 5 | import 'package:test/test.dart'; 6 | 7 | void main() { 8 | test('ensure_build', expectBuildClean); 9 | } 10 | -------------------------------------------------------------------------------- /lib/nenuphar_cli.dart: -------------------------------------------------------------------------------- 1 | /// nenuphar_cli, A Very Good Project created by Very Good CLI. 2 | /// 3 | /// ```sh 4 | /// # activate nenuphar_cli 5 | /// dart pub global activate nenuphar_cli 6 | /// 7 | /// # see usage 8 | /// nenuphar_cli --help 9 | /// ``` 10 | library nenuphar_cli; 11 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | enable-beta-ecosystems: true 3 | updates: 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "daily" 8 | - package-ecosystem: "pub" 9 | directory: "/" 10 | schedule: 11 | interval: "daily" 12 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://www.dartlang.org/guides/libraries/private-files 2 | 3 | # Files and directories created by pub 4 | .dart_tool/ 5 | .packages 6 | pubspec.lock 7 | 8 | # Files and directories created by dart_frog 9 | build/ 10 | .dart_frog 11 | 12 | # Test related files 13 | coverage/ -------------------------------------------------------------------------------- /example/components/todos.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "properties": { 4 | "id": { 5 | "type": "integer", 6 | "format": "int64" 7 | }, 8 | "name": { 9 | "type": "string" 10 | }, 11 | "completed": { 12 | "type": "boolean" 13 | } 14 | } 15 | } -------------------------------------------------------------------------------- /example/lib/services/auth_service.dart: -------------------------------------------------------------------------------- 1 | /// Authentication service used by the / _middleware 2 | class AuthService { 3 | /// Assert that the user is authenticated 4 | void authGuard( 5 | String? userName, 6 | ) { 7 | if (userName == null) { 8 | throw Exception('Unauthorized'); 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://www.dartlang.org/guides/libraries/private-files 2 | 3 | # Files and directories created by pub 4 | .dart_tool/ 5 | .packages 6 | build/ 7 | pubspec.lock 8 | 9 | # Files generated during tests 10 | .test_coverage.dart 11 | coverage/ 12 | .test_runner.dart 13 | 14 | # Android studio and IntelliJ 15 | .idea -------------------------------------------------------------------------------- /lib/src/extensions/string_extension.dart: -------------------------------------------------------------------------------- 1 | final pathParamRegex = RegExp(r'\{[a-zA-z0-9]*\}'); 2 | 3 | extension StringExtention on String { 4 | bool endsWithPathParam() { 5 | return isNotEmpty && 6 | contains('/') && 7 | trim() != '/' && 8 | pathParamRegex.hasMatch( 9 | split('/').where((part) => part.isNotEmpty).last, 10 | ); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # .github/workflows/publish.yml 2 | name: Publish to pub.dev 3 | 4 | on: 5 | push: 6 | tags: 7 | - '[0-9]+.[0-9]+.[0-9]+*' 8 | 9 | # Publish using the reusable workflow from dart-lang. 10 | jobs: 11 | publish: 12 | permissions: 13 | id-token: write # Required for authentication using OIDC 14 | uses: dart-lang/setup-dart/.github/workflows/publish.yml@v1 -------------------------------------------------------------------------------- /example/routes/todos/_middleware.dart: -------------------------------------------------------------------------------- 1 | import 'package:dart_frog/dart_frog.dart'; 2 | import 'package:example/services/todo_service.dart'; 3 | 4 | final TodoService _todoService = TodoService(); 5 | 6 | Handler middleware(Handler handler) { 7 | return handler.use(requestLogger()).use(todoService()); 8 | } 9 | 10 | Middleware todoService() { 11 | return provider((context) => _todoService); 12 | } 13 | -------------------------------------------------------------------------------- /example/pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: example 2 | description: An new Dart Frog application 3 | version: 1.0.0+1 4 | publish_to: none 5 | 6 | environment: 7 | sdk: ">=3.0.0 <4.0.0" 8 | 9 | dependencies: 10 | dart_frog: ^1.0.0 11 | json_annotation: ^4.8.1 12 | 13 | dev_dependencies: 14 | build_runner: ^2.4.6 15 | json_serializable: ^6.7.1 16 | mocktail: ^0.3.0 17 | test: ^1.19.2 18 | very_good_analysis: ^5.0.0 19 | -------------------------------------------------------------------------------- /lib/src/models/tag.dart: -------------------------------------------------------------------------------- 1 | import 'package:json_annotation/json_annotation.dart'; 2 | 3 | part 'tag.g.dart'; 4 | 5 | @JsonSerializable() 6 | class Tag { 7 | Tag({ 8 | required this.name, 9 | required this.description, 10 | }); 11 | 12 | factory Tag.fromJson(Map json) => _$TagFromJson(json); 13 | 14 | final String name; 15 | final String description; 16 | 17 | Map toJson() => _$TagToJson(this); 18 | } 19 | -------------------------------------------------------------------------------- /lib/src/models/license.dart: -------------------------------------------------------------------------------- 1 | import 'package:json_annotation/json_annotation.dart'; 2 | 3 | part 'license.g.dart'; 4 | 5 | @JsonSerializable() 6 | class License { 7 | const License({ 8 | this.name = '', 9 | this.url = '', 10 | }); 11 | 12 | factory License.fromJson(Map json) => 13 | _$LicenseFromJson(json); 14 | 15 | final String name; 16 | final String url; 17 | 18 | Map toJson() => _$LicenseToJson(this); 19 | } 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: A new feature to be added to the project 4 | title: "feat: " 5 | labels: feature 6 | --- 7 | 8 | **Description** 9 | 10 | Clearly describe what you are looking to add. The more context the better. 11 | 12 | **Requirements** 13 | 14 | - [ ] Checklist of requirements to be fulfilled 15 | 16 | **Additional Context** 17 | 18 | Add any other context or screenshots about the feature request go here. 19 | -------------------------------------------------------------------------------- /lib/src/models/media_type.dart: -------------------------------------------------------------------------------- 1 | import 'package:json_annotation/json_annotation.dart'; 2 | import 'package:nenuphar_cli/src/models/models.dart'; 3 | 4 | part 'media_type.g.dart'; 5 | 6 | @JsonSerializable() 7 | class MediaType { 8 | const MediaType({ 9 | this.schema, 10 | }); 11 | 12 | factory MediaType.fromJson(Map json) => 13 | _$MediaTypeFromJson(json); 14 | 15 | final Schema? schema; 16 | 17 | Map toJson() => _$MediaTypeToJson(this); 18 | } 19 | -------------------------------------------------------------------------------- /lib/src/models/server.dart: -------------------------------------------------------------------------------- 1 | import 'package:json_annotation/json_annotation.dart'; 2 | 3 | part 'server.g.dart'; 4 | 5 | @JsonSerializable() 6 | class Server { 7 | const Server({ 8 | this.url = 'http://localhost:8080', 9 | this.description = 'Local server', 10 | }); 11 | 12 | factory Server.fromJson(Map json) => _$ServerFromJson(json); 13 | 14 | final String url; 15 | final String description; 16 | 17 | Map toJson() => _$ServerToJson(this); 18 | } 19 | -------------------------------------------------------------------------------- /lib/src/models/header.dart: -------------------------------------------------------------------------------- 1 | import 'package:json_annotation/json_annotation.dart'; 2 | import 'package:nenuphar_cli/src/models/schema.dart'; 3 | 4 | part 'header.g.dart'; 5 | 6 | @JsonSerializable() 7 | class Header { 8 | const Header({ 9 | required this.description, 10 | required this.schema, 11 | }); 12 | 13 | factory Header.fromJson(Map json) => _$HeaderFromJson(json); 14 | 15 | final String description; 16 | final Schema schema; 17 | 18 | Map toJson() => _$HeaderToJson(this); 19 | } 20 | -------------------------------------------------------------------------------- /lib/src/models/contact.dart: -------------------------------------------------------------------------------- 1 | import 'package:json_annotation/json_annotation.dart'; 2 | 3 | part 'contact.g.dart'; 4 | 5 | @JsonSerializable() 6 | class Contact { 7 | const Contact({ 8 | this.name = 'none', 9 | this.url = 'http://localhost', 10 | this.email = 'none@api.com', 11 | }); 12 | 13 | factory Contact.fromJson(Map json) => 14 | _$ContactFromJson(json); 15 | 16 | final String? name; 17 | final String? url; 18 | final String? email; 19 | 20 | Map toJson() => _$ContactToJson(this); 21 | } 22 | -------------------------------------------------------------------------------- /example/components/_security.json: -------------------------------------------------------------------------------- 1 | { 2 | "todos_basic_auth": { 3 | "type": "http", 4 | "scheme": "basic" 5 | }, 6 | "todos_api_key": { 7 | "type": "apiKey", 8 | "name": "api_key", 9 | "in": "header" 10 | }, 11 | "todos_oauth": { 12 | "type": "oauth2", 13 | "flows": { 14 | "implicit": { 15 | "authorizationUrl": "https://nenuphar.io/oauth/authorize", 16 | "scopes": { 17 | "write:todos": "modify todos", 18 | "read:todos": "read your todos" 19 | } 20 | } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /lib/src/models/models.dart: -------------------------------------------------------------------------------- 1 | export 'components.dart'; 2 | export 'contact.dart'; 3 | export 'external_documentation.dart'; 4 | export 'header.dart'; 5 | export 'info.dart'; 6 | export 'license.dart'; 7 | export 'media_type.dart'; 8 | export 'method.dart'; 9 | export 'oauth_flow.dart'; 10 | export 'oauth_flows.dart'; 11 | export 'openapi.dart'; 12 | export 'parameter.dart'; 13 | export 'paths.dart'; 14 | export 'request_body.dart'; 15 | export 'response_body.dart'; 16 | export 'schema.dart'; 17 | export 'security_scheme.dart'; 18 | export 'server.dart'; 19 | export 'tag.dart'; 20 | -------------------------------------------------------------------------------- /lib/src/models/external_documentation.dart: -------------------------------------------------------------------------------- 1 | import 'package:json_annotation/json_annotation.dart'; 2 | 3 | part 'external_documentation.g.dart'; 4 | 5 | @JsonSerializable() 6 | class ExternalDocumentation { 7 | const ExternalDocumentation({ 8 | this.description = '', 9 | this.url = 'http://localhost/', 10 | }); 11 | 12 | factory ExternalDocumentation.fromJson(Map json) => 13 | _$ExternalDocumentationFromJson(json); 14 | 15 | final String? description; 16 | final String? url; 17 | 18 | Map toJson() => _$ExternalDocumentationToJson(this); 19 | } 20 | -------------------------------------------------------------------------------- /example/routes/_middleware.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:dart_frog/dart_frog.dart'; 4 | import 'package:example/services/auth_service.dart'; 5 | 6 | final AuthService authService = AuthService(); 7 | 8 | Handler middleware(Handler handler) { 9 | return (context) async { 10 | // Get the user name header 11 | final userName = context.request.headers['User-Name']; 12 | 13 | try { 14 | authService.authGuard( 15 | userName, 16 | ); 17 | } catch (e) { 18 | return Response(statusCode: HttpStatus.unauthorized); 19 | } 20 | 21 | return handler(context); 22 | }; 23 | } 24 | -------------------------------------------------------------------------------- /lib/src/models/tag.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'tag.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | Tag _$TagFromJson(Map json) => Tag( 10 | name: json['name'] as String, 11 | description: json['description'] as String, 12 | ); 13 | 14 | Map _$TagToJson(Tag instance) => { 15 | 'name': instance.name, 16 | 'description': instance.description, 17 | }; 18 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # example 2 | 3 | [![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link] 4 | [![License: MIT][license_badge]][license_link] 5 | [![Powered by Dart Frog](https://img.shields.io/endpoint?url=https://tinyurl.com/dartfrog-badge)](https://dartfrog.vgv.dev) 6 | 7 | An example application built with dart_frog 8 | 9 | [license_badge]: https://img.shields.io/badge/license-MIT-blue.svg 10 | [license_link]: https://opensource.org/licenses/MIT 11 | [very_good_analysis_badge]: https://img.shields.io/badge/style-very_good_analysis-B22C89.svg 12 | [very_good_analysis_link]: https://pub.dev/packages/very_good_analysis -------------------------------------------------------------------------------- /lib/src/models/license.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'license.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | License _$LicenseFromJson(Map json) => License( 10 | name: json['name'] as String? ?? '', 11 | url: json['url'] as String? ?? '', 12 | ); 13 | 14 | Map _$LicenseToJson(License instance) => { 15 | 'name': instance.name, 16 | 'url': instance.url, 17 | }; 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Create a report to help us improve 4 | title: "fix: " 5 | labels: bug 6 | --- 7 | 8 | **Description** 9 | 10 | A clear and concise description of what the bug is. 11 | 12 | **Steps To Reproduce** 13 | 14 | 1. Go to '...' 15 | 2. Click on '....' 16 | 3. Scroll down to '....' 17 | 4. See error 18 | 19 | **Expected Behavior** 20 | 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | 25 | If applicable, add screenshots to help explain your problem. 26 | 27 | **Additional Context** 28 | 29 | Add any other context about the problem here. 30 | -------------------------------------------------------------------------------- /lib/src/models/request_body.dart: -------------------------------------------------------------------------------- 1 | import 'package:json_annotation/json_annotation.dart'; 2 | import 'package:nenuphar_cli/src/models/models.dart'; 3 | 4 | part 'request_body.g.dart'; 5 | 6 | @JsonSerializable() 7 | class RequestBody { 8 | const RequestBody({ 9 | this.description = '', 10 | this.required = false, 11 | this.content = const {}, 12 | }); 13 | 14 | factory RequestBody.fromJson(Map json) => 15 | _$RequestBodyFromJson(json); 16 | 17 | final String? description; 18 | final bool required; 19 | final Map content; 20 | 21 | Map toJson() => _$RequestBodyToJson(this); 22 | } 23 | -------------------------------------------------------------------------------- /lib/src/models/response_body.dart: -------------------------------------------------------------------------------- 1 | import 'package:json_annotation/json_annotation.dart'; 2 | import 'package:nenuphar_cli/src/models/models.dart'; 3 | 4 | part 'response_body.g.dart'; 5 | 6 | @JsonSerializable() 7 | class ResponseBody { 8 | const ResponseBody({ 9 | this.description = '', 10 | this.headers = const {}, 11 | this.content = const {}, 12 | }); 13 | 14 | factory ResponseBody.fromJson(Map json) => 15 | _$ResponseBodyFromJson(json); 16 | 17 | final String description; 18 | final Map headers; 19 | final Map content; 20 | 21 | Map toJson() => _$ResponseBodyToJson(this); 22 | } 23 | -------------------------------------------------------------------------------- /lib/src/models/header.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'header.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | Header _$HeaderFromJson(Map json) => Header( 10 | description: json['description'] as String, 11 | schema: Schema.fromJson(json['schema'] as Map), 12 | ); 13 | 14 | Map _$HeaderToJson(Header instance) => { 15 | 'description': instance.description, 16 | 'schema': instance.schema, 17 | }; 18 | -------------------------------------------------------------------------------- /lib/src/models/server.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'server.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | Server _$ServerFromJson(Map json) => Server( 10 | url: json['url'] as String? ?? 'http://localhost:8080', 11 | description: json['description'] as String? ?? 'Local server', 12 | ); 13 | 14 | Map _$ServerToJson(Server instance) => { 15 | 'url': instance.url, 16 | 'description': instance.description, 17 | }; 18 | -------------------------------------------------------------------------------- /bin/nenuphar.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:nenuphar_cli/src/command_runner.dart'; 4 | 5 | Future main(List args) async { 6 | await _flushThenExit(await NenupharCliCommandRunner().run(args)); 7 | } 8 | 9 | /// Flushes the stdout and stderr streams, then exits the program with the given 10 | /// status code. 11 | /// 12 | /// This returns a Future that will never complete, since the program will have 13 | /// exited already. This is useful to prevent Future chains from proceeding 14 | /// after you've decided to exit. 15 | Future _flushThenExit(int status) { 16 | return Future.wait([stdout.close(), stderr.close()]) 17 | .then((_) => exit(status)); 18 | } 19 | -------------------------------------------------------------------------------- /example/lib/models/todos.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'todos.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | Todo _$TodoFromJson(Map json) => Todo( 10 | id: json['id'] as int? ?? 0, 11 | name: json['name'] as String? ?? '', 12 | completed: json['completed'] as bool? ?? false, 13 | ); 14 | 15 | Map _$TodoToJson(Todo instance) => { 16 | 'id': instance.id, 17 | 'name': instance.name, 18 | 'completed': instance.completed, 19 | }; 20 | -------------------------------------------------------------------------------- /example/test/routes/index_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:dart_frog/dart_frog.dart'; 4 | import 'package:mocktail/mocktail.dart'; 5 | import 'package:test/test.dart'; 6 | 7 | import '../../routes/index.dart' as route; 8 | 9 | class _MockRequestContext extends Mock implements RequestContext {} 10 | 11 | void main() { 12 | group('GET /', () { 13 | test('responds with a 200 and "Welcome to Dart Frog!".', () { 14 | final context = _MockRequestContext(); 15 | final response = route.onRequest(context); 16 | expect(response.statusCode, equals(HttpStatus.ok)); 17 | expect( 18 | response.body(), 19 | completion(equals('Welcome to Dart Frog!')), 20 | ); 21 | }); 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: nenuphar_cli 2 | description: An openapi generation CLI for Dart Frog created by Very Good CLI. 3 | version: 0.2.2 4 | homepage: https://piotrfleury.github.io/nenuphar_cli/ 5 | repository: https://github.com/PiotrFLEURY/nenuphar_cli 6 | 7 | environment: 8 | sdk: ">=3.0.0 <4.0.0" 9 | 10 | dependencies: 11 | args: ^2.4.1 12 | cli_completion: ">=0.3.0 <0.6.0" 13 | file: ">=6.1.4 <8.0.0" 14 | json_annotation: ^4.8.1 15 | mason_logger: ^0.2.5 16 | pub_updater: ">=0.3.0 <0.5.0" 17 | 18 | dev_dependencies: 19 | build_runner: ^2.4.6 20 | build_verify: ^3.1.0 21 | build_version: ^2.1.1 22 | json_serializable: ^6.7.1 23 | mocktail: ^1.0.0 24 | test: ^1.24.2 25 | very_good_analysis: ^5.0.0 26 | 27 | executables: 28 | nenuphar: 29 | -------------------------------------------------------------------------------- /example/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | SwaggerUI 11 | 12 | 13 | 14 |
15 | 16 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 8 | 9 | ## Description 10 | 11 | 12 | 13 | ## Type of Change 14 | 15 | 16 | 17 | - [ ] ✨ New feature (non-breaking change which adds functionality) 18 | - [ ] 🛠️ Bug fix (non-breaking change which fixes an issue) 19 | - [ ] ❌ Breaking change (fix or feature that would cause existing functionality to change) 20 | - [ ] 🧹 Code refactor 21 | - [ ] ✅ Build configuration change 22 | - [ ] 📝 Documentation 23 | - [ ] 🗑️ Chore 24 | -------------------------------------------------------------------------------- /lib/src/models/paths.dart: -------------------------------------------------------------------------------- 1 | import 'package:json_annotation/json_annotation.dart'; 2 | import 'package:nenuphar_cli/src/models/method.dart'; 3 | 4 | part 'paths.g.dart'; 5 | 6 | @JsonSerializable() 7 | class Paths { 8 | const Paths({ 9 | this.get, 10 | this.post, 11 | this.put, 12 | this.delete, 13 | this.patch, 14 | this.head, 15 | this.options, 16 | this.trace, 17 | }); 18 | 19 | factory Paths.fromJson(Map json) => _$PathsFromJson(json); 20 | 21 | final Method? get; 22 | final Method? post; 23 | final Method? put; 24 | final Method? delete; 25 | final Method? patch; 26 | final Method? head; 27 | final Method? options; 28 | final Method? trace; 29 | 30 | Map toJson() => _$PathsToJson(this); 31 | } 32 | -------------------------------------------------------------------------------- /.github/cspell.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2", 3 | "$schema": "https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json", 4 | "dictionaries": ["vgv_allowed", "vgv_forbidden"], 5 | "dictionaryDefinitions": [ 6 | { 7 | "name": "vgv_allowed", 8 | "path": "https://raw.githubusercontent.com/verygoodopensource/very_good_dictionaries/main/allowed.txt", 9 | "description": "Allowed VGV Spellings" 10 | }, 11 | { 12 | "name": "vgv_forbidden", 13 | "path": "https://raw.githubusercontent.com/verygoodopensource/very_good_dictionaries/main/forbidden.txt", 14 | "description": "Forbidden VGV Spellings" 15 | } 16 | ], 17 | "useGitignore": true, 18 | "words": [ 19 | "nenuphar", 20 | "openapi", 21 | "github", 22 | "pubignore" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /lib/src/models/media_type.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'media_type.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | MediaType _$MediaTypeFromJson(Map json) => MediaType( 10 | schema: json['schema'] == null 11 | ? null 12 | : Schema.fromJson(json['schema'] as Map), 13 | ); 14 | 15 | Map _$MediaTypeToJson(MediaType instance) { 16 | final val = {}; 17 | 18 | void writeNotNull(String key, dynamic value) { 19 | if (value != null) { 20 | val[key] = value; 21 | } 22 | } 23 | 24 | writeNotNull('schema', instance.schema); 25 | return val; 26 | } 27 | -------------------------------------------------------------------------------- /lib/src/models/info.dart: -------------------------------------------------------------------------------- 1 | import 'package:json_annotation/json_annotation.dart'; 2 | import 'package:nenuphar_cli/src/models/contact.dart'; 3 | import 'package:nenuphar_cli/src/models/license.dart'; 4 | 5 | part 'info.g.dart'; 6 | 7 | @JsonSerializable() 8 | class Info { 9 | const Info({ 10 | this.title = 'A sample API', 11 | this.description = 'A sample API', 12 | this.termsOfService = 'http://localhost', 13 | this.contact = const Contact(), 14 | this.license = const License(), 15 | this.version = '0.0.0', 16 | }); 17 | 18 | factory Info.fromJson(Map json) => _$InfoFromJson(json); 19 | 20 | final String? title; 21 | final String? description; 22 | final String? termsOfService; 23 | final Contact? contact; 24 | final License? license; 25 | final String? version; 26 | 27 | Map toJson() => _$InfoToJson(this); 28 | } 29 | -------------------------------------------------------------------------------- /lib/src/models/openapi.dart: -------------------------------------------------------------------------------- 1 | import 'package:json_annotation/json_annotation.dart'; 2 | import 'package:nenuphar_cli/src/models/models.dart'; 3 | 4 | part 'openapi.g.dart'; 5 | 6 | @JsonSerializable() 7 | class OpenApi { 8 | OpenApi({ 9 | this.openapi = '3.0.3', 10 | this.info = const Info(), 11 | this.externalDocs = const ExternalDocumentation(), 12 | this.servers = const [Server()], 13 | this.tags, 14 | this.paths = const {}, 15 | this.components, 16 | }); 17 | 18 | factory OpenApi.fromJson(Map json) => 19 | _$OpenApiFromJson(json); 20 | 21 | final String openapi; 22 | final Info info; 23 | final ExternalDocumentation externalDocs; 24 | final List servers; 25 | List? tags; 26 | Components? components; 27 | 28 | Map paths; 29 | 30 | Map toJson() => _$OpenApiToJson(this); 31 | } 32 | -------------------------------------------------------------------------------- /example/nenuphar.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.0.3", 3 | "info": { 4 | "title": "Nenuphar API Documentation", 5 | "description": "Example of Todo API documented with Nenuphar", 6 | "termsOfService": "https://tosdr.org/", 7 | "contact": { 8 | "name": "Piotr FLEURY", 9 | "url": "https://github.com/PiotrFLEURY/nenuphar_cli", 10 | "email": "piotr.fleury@gmail.com" 11 | }, 12 | "license": { 13 | "name": "BSD 3-Clause License", 14 | "url": "https://github.com/PiotrFLEURY/nenuphar_cli/blob/main/LICENSE" 15 | }, 16 | "version": "1.0.0" 17 | }, 18 | "externalDocs": { 19 | "description": "Nenuphar CLI detailed documentation", 20 | "url": "https://piotrfleury.github.io/nenuphar_cli/" 21 | }, 22 | "servers": [ 23 | { 24 | "url": "http://localhost:8080", 25 | "description": "Local server" 26 | } 27 | ], 28 | "paths": {} 29 | } -------------------------------------------------------------------------------- /lib/src/models/contact.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'contact.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | Contact _$ContactFromJson(Map json) => Contact( 10 | name: json['name'] as String? ?? 'none', 11 | url: json['url'] as String? ?? 'http://localhost', 12 | email: json['email'] as String? ?? 'none@api.com', 13 | ); 14 | 15 | Map _$ContactToJson(Contact instance) { 16 | final val = {}; 17 | 18 | void writeNotNull(String key, dynamic value) { 19 | if (value != null) { 20 | val[key] = value; 21 | } 22 | } 23 | 24 | writeNotNull('name', instance.name); 25 | writeNotNull('url', instance.url); 26 | writeNotNull('email', instance.email); 27 | return val; 28 | } 29 | -------------------------------------------------------------------------------- /lib/src/models/schema.dart: -------------------------------------------------------------------------------- 1 | import 'package:json_annotation/json_annotation.dart'; 2 | 3 | part 'schema.g.dart'; 4 | 5 | @JsonSerializable() 6 | class Schema { 7 | const Schema({ 8 | this.type, 9 | this.format, 10 | this.requiredProperties, 11 | this.properties, 12 | this.ref, 13 | this.items, 14 | this.minimum, 15 | this.maximum, 16 | }); 17 | 18 | factory Schema.fromJson(Map json) => _$SchemaFromJson(json); 19 | 20 | factory Schema.emptyObject() => const Schema(type: 'object'); 21 | 22 | final String? type; 23 | final String? format; 24 | 25 | @JsonKey(name: 'required') 26 | final List? requiredProperties; 27 | final Map? properties; 28 | 29 | @JsonKey(name: r'$ref') 30 | final String? ref; 31 | 32 | final Schema? items; 33 | 34 | final int? minimum; 35 | 36 | final int? maximum; 37 | 38 | Map toJson() => _$SchemaToJson(this); 39 | } 40 | -------------------------------------------------------------------------------- /example/lib/models/todos.dart: -------------------------------------------------------------------------------- 1 | import 'package:json_annotation/json_annotation.dart'; 2 | 3 | part 'todos.g.dart'; 4 | 5 | /// Todo model 6 | @JsonSerializable() 7 | class Todo { 8 | /// 9 | /// Constructor 10 | /// [id] technical id 11 | /// [name] task name 12 | /// [completed] defines if the task is completed 13 | /// 14 | const Todo({ 15 | this.id = 0, 16 | this.name = '', 17 | this.completed = false, 18 | }); 19 | 20 | /// Creates a new Todo from a json 21 | /// [json] json 22 | /// Returns a new Todo 23 | /// Throws an exception if the json is not valid 24 | static Todo fromJson(Map json) => _$TodoFromJson(json); 25 | 26 | /// Technical id 27 | final int id; 28 | 29 | /// task name 30 | final String name; 31 | 32 | /// Defines if the task is completed 33 | final bool completed; 34 | 35 | /// Converts the Todo to a json 36 | /// Returns a json 37 | Map toJson() => _$TodoToJson(this); 38 | } 39 | -------------------------------------------------------------------------------- /lib/src/models/components.dart: -------------------------------------------------------------------------------- 1 | import 'package:json_annotation/json_annotation.dart'; 2 | import 'package:nenuphar_cli/src/models/models.dart'; 3 | 4 | part 'components.g.dart'; 5 | 6 | /// Holds a set of reusable objects for different aspects of the OAS. 7 | /// All objects defined within the components object will have no effect on 8 | /// the API unless they are explicitly referenced from properties outside 9 | /// the components object. 10 | @JsonSerializable() 11 | class Components { 12 | const Components({ 13 | this.schemas = const {}, 14 | this.securitySchemes = const {}, 15 | }); 16 | 17 | factory Components.fromJson(Map json) => 18 | _$ComponentsFromJson(json); 19 | 20 | /// An object to hold reusable Schema Objects. 21 | final Map schemas; 22 | 23 | /// An object to hold reusable Security Scheme Objects. 24 | final Map securitySchemes; 25 | 26 | Map toJson() => _$ComponentsToJson(this); 27 | } 28 | -------------------------------------------------------------------------------- /lib/src/models/external_documentation.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'external_documentation.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | ExternalDocumentation _$ExternalDocumentationFromJson( 10 | Map json) => 11 | ExternalDocumentation( 12 | description: json['description'] as String? ?? '', 13 | url: json['url'] as String? ?? 'http://localhost/', 14 | ); 15 | 16 | Map _$ExternalDocumentationToJson( 17 | ExternalDocumentation instance) { 18 | final val = {}; 19 | 20 | void writeNotNull(String key, dynamic value) { 21 | if (value != null) { 22 | val[key] = value; 23 | } 24 | } 25 | 26 | writeNotNull('description', instance.description); 27 | writeNotNull('url', instance.url); 28 | return val; 29 | } 30 | -------------------------------------------------------------------------------- /lib/src/models/components.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'components.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | Components _$ComponentsFromJson(Map json) => Components( 10 | schemas: (json['schemas'] as Map?)?.map( 11 | (k, e) => MapEntry(k, Schema.fromJson(e as Map)), 12 | ) ?? 13 | const {}, 14 | securitySchemes: (json['securitySchemes'] as Map?)?.map( 15 | (k, e) => 16 | MapEntry(k, SecurityScheme.fromJson(e as Map)), 17 | ) ?? 18 | const {}, 19 | ); 20 | 21 | Map _$ComponentsToJson(Components instance) => 22 | { 23 | 'schemas': instance.schemas, 24 | 'securitySchemes': instance.securitySchemes, 25 | }; 26 | -------------------------------------------------------------------------------- /example/lib/services/todo_service.dart: -------------------------------------------------------------------------------- 1 | import 'package:example/models/todos.dart'; 2 | 3 | /// Manage the todos 4 | class TodoService { 5 | final _todos = []; 6 | 7 | /// Add a todo to the list 8 | void add(Todo todo) { 9 | _todos.add(todo); 10 | } 11 | 12 | /// Get the todo list 13 | List get todos => _todos; 14 | 15 | /// Get the todo list filtered by completed 16 | List getTodosByCompleted({ 17 | bool completed = false, 18 | }) { 19 | return _todos.where((todo) => todo.completed == completed).toList(); 20 | } 21 | 22 | /// Get a todo by id 23 | Todo getById(int id) { 24 | return _todos.firstWhere((todo) => todo.id == id, orElse: Todo.new); 25 | } 26 | 27 | /// Remove a todo by id 28 | void removeById(int id) { 29 | _todos.removeWhere((todo) => todo.id == id); 30 | } 31 | 32 | /// Update a todo 33 | void update(Todo todo) { 34 | final index = _todos.indexWhere((element) => element.id == todo.id); 35 | if (index != -1) { 36 | _todos[index] = todo; 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /lib/src/models/parameter.dart: -------------------------------------------------------------------------------- 1 | import 'package:json_annotation/json_annotation.dart'; 2 | import 'package:nenuphar_cli/src/models/models.dart'; 3 | 4 | part 'parameter.g.dart'; 5 | 6 | @JsonSerializable() 7 | class Parameter { 8 | const Parameter({ 9 | required this.name, 10 | this.inLocation = InLocation.query, 11 | this.description, 12 | this.required = false, 13 | this.deprecated = false, 14 | this.allowEmptyValue = false, 15 | this.schema, 16 | }); 17 | 18 | factory Parameter.fromJson(Map json) => 19 | _$ParameterFromJson(json); 20 | 21 | final String name; 22 | @JsonKey(name: 'in') 23 | final InLocation inLocation; 24 | final String? description; 25 | final bool required; 26 | final bool deprecated; 27 | final bool allowEmptyValue; 28 | final Schema? schema; 29 | 30 | Map toJson() => _$ParameterToJson(this); 31 | } 32 | 33 | // Enum InLocation 34 | // possible values are "query", "header", "path", "cookie" 35 | enum InLocation { 36 | query, 37 | header, 38 | path, 39 | cookie, 40 | } 41 | -------------------------------------------------------------------------------- /test/src/models/openapi_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:nenuphar_cli/src/models/models.dart'; 4 | import 'package:test/test.dart'; 5 | 6 | void main() { 7 | test('jsonEncode openApi', () { 8 | // GIVEN 9 | final openApi = OpenApi( 10 | info: const Info( 11 | title: 'Todo API', 12 | version: '1.0.0', 13 | contact: Contact( 14 | name: 'John Doe', 15 | email: '', 16 | url: 'https://example.com', 17 | ), 18 | license: License( 19 | name: 'MIT', 20 | url: 'https://opensource.org/licenses/MIT', 21 | ), 22 | ), 23 | paths: { 24 | '/todo': const Paths( 25 | get: Method( 26 | responses: { 27 | 200: ResponseBody( 28 | description: 'A list of todos.', 29 | ), 30 | }, 31 | ), 32 | ), 33 | }, 34 | ); 35 | 36 | // WHEN 37 | final json = jsonEncode(openApi.toJson()); 38 | 39 | // THEN 40 | expect(json, isNotEmpty); 41 | }); 42 | } 43 | -------------------------------------------------------------------------------- /docs/development.md: -------------------------------------------------------------------------------- 1 | # Development documentation 2 | 3 | Hi developer 👋 4 | 5 | This documentation is for you. 6 | 7 | ## Getting Started 🚀 8 | 9 | If the CLI application is available on [pub](https://pub.dev), activate globally via: 10 | 11 | ```sh 12 | dart pub global activate nenuphar_cli 13 | ``` 14 | 15 | Or locally via: 16 | 17 | ```sh 18 | dart pub global activate --source=path 19 | ``` 20 | 21 | ## Usage 22 | 23 | Please consult the [documentation](docs/index.md) for more information. 24 | 25 | ## Running Tests with coverage 🧪 26 | 27 | To run all unit tests use the following command: 28 | 29 | ```sh 30 | $ dart pub global activate coverage 1.2.0 31 | $ dart test --coverage=coverage 32 | $ dart pub global run coverage:format_coverage --lcov --in=coverage --out=coverage/lcov.info 33 | ``` 34 | 35 | To view the generated coverage report you can use [lcov](https://github.com/linux-test-project/lcov) 36 | . 37 | 38 | ```sh 39 | # Generate Coverage Report 40 | $ genhtml coverage/lcov.info -o coverage/ 41 | 42 | # Open Coverage Report 43 | $ open coverage/index.html 44 | ``` -------------------------------------------------------------------------------- /lib/src/models/response_body.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'response_body.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | ResponseBody _$ResponseBodyFromJson(Map json) => ResponseBody( 10 | description: json['description'] as String? ?? '', 11 | headers: (json['headers'] as Map?)?.map( 12 | (k, e) => MapEntry(k, Header.fromJson(e as Map)), 13 | ) ?? 14 | const {}, 15 | content: (json['content'] as Map?)?.map( 16 | (k, e) => 17 | MapEntry(k, MediaType.fromJson(e as Map)), 18 | ) ?? 19 | const {}, 20 | ); 21 | 22 | Map _$ResponseBodyToJson(ResponseBody instance) => 23 | { 24 | 'description': instance.description, 25 | 'headers': instance.headers, 26 | 'content': instance.content, 27 | }; 28 | -------------------------------------------------------------------------------- /lib/src/models/request_body.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'request_body.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | RequestBody _$RequestBodyFromJson(Map json) => RequestBody( 10 | description: json['description'] as String? ?? '', 11 | required: json['required'] as bool? ?? false, 12 | content: (json['content'] as Map?)?.map( 13 | (k, e) => 14 | MapEntry(k, MediaType.fromJson(e as Map)), 15 | ) ?? 16 | const {}, 17 | ); 18 | 19 | Map _$RequestBodyToJson(RequestBody instance) { 20 | final val = {}; 21 | 22 | void writeNotNull(String key, dynamic value) { 23 | if (value != null) { 24 | val[key] = value; 25 | } 26 | } 27 | 28 | writeNotNull('description', instance.description); 29 | val['required'] = instance.required; 30 | val['content'] = instance.content; 31 | return val; 32 | } 33 | -------------------------------------------------------------------------------- /example/routes/todos/[id].dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'dart:io'; 3 | 4 | import 'package:dart_frog/dart_frog.dart'; 5 | import 'package:example/services/todo_service.dart'; 6 | 7 | /// 8 | /// The /todos/[id] routes 9 | /// 10 | /// @Allow(GET, DELETE) - Allow only GET and DELETE methods 11 | /// 12 | /// @Header(User-Name) - The user name header 13 | /// 14 | /// @Security(todos_api_key) - The api key security scheme defined in 15 | /// components/_security.dart 16 | /// 17 | Future onRequest( 18 | RequestContext context, 19 | String id, 20 | ) async { 21 | final todosService = context.read(); 22 | 23 | switch (context.request.method) { 24 | case HttpMethod.get: 25 | return Response(body: jsonEncode(todosService.getById(int.parse(id)))); 26 | case HttpMethod.delete: 27 | todosService.removeById(int.parse(id)); 28 | return Response(body: jsonEncode(todosService.todos)); 29 | case HttpMethod.post: 30 | case HttpMethod.head: 31 | case HttpMethod.options: 32 | case HttpMethod.patch: 33 | case HttpMethod.put: 34 | return Response(statusCode: HttpStatus.methodNotAllowed); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /lib/src/models/oauth_flow.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'oauth_flow.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | OAuthFlow _$OAuthFlowFromJson(Map json) => OAuthFlow( 10 | authorizationUrl: json['authorizationUrl'] as String?, 11 | tokenUrl: json['tokenUrl'] as String?, 12 | refreshUrl: json['refreshUrl'] as String?, 13 | scopes: (json['scopes'] as Map?)?.map( 14 | (k, e) => MapEntry(k, e as String), 15 | ) ?? 16 | const {}, 17 | ); 18 | 19 | Map _$OAuthFlowToJson(OAuthFlow instance) { 20 | final val = {}; 21 | 22 | void writeNotNull(String key, dynamic value) { 23 | if (value != null) { 24 | val[key] = value; 25 | } 26 | } 27 | 28 | writeNotNull('authorizationUrl', instance.authorizationUrl); 29 | writeNotNull('tokenUrl', instance.tokenUrl); 30 | writeNotNull('refreshUrl', instance.refreshUrl); 31 | val['scopes'] = instance.scopes; 32 | return val; 33 | } 34 | -------------------------------------------------------------------------------- /lib/src/models/oauth_flows.dart: -------------------------------------------------------------------------------- 1 | import 'package:json_annotation/json_annotation.dart'; 2 | import 'package:nenuphar_cli/src/models/models.dart'; 3 | 4 | part 'oauth_flows.g.dart'; 5 | 6 | /// 7 | /// https://swagger.io/specification/v3/#oauth-flows-object 8 | /// 9 | /// Allows configuration of the supported OAuth Flows. 10 | /// 11 | @JsonSerializable() 12 | class OAuthFlows { 13 | const OAuthFlows({ 14 | this.implicit, 15 | this.password, 16 | this.clientCredentials, 17 | this.authorizationCode, 18 | }); 19 | 20 | factory OAuthFlows.fromJson(Map json) => 21 | _$OAuthFlowsFromJson(json); 22 | 23 | /// Configuration for the OAuth Implicit flow 24 | final OAuthFlow? implicit; 25 | 26 | /// Configuration for the OAuth Resource Owner Password flow 27 | final OAuthFlow? password; 28 | 29 | /// Configuration for the OAuth Client Credentials flow. 30 | /// Previously called application in OpenAPI 2.0. 31 | final OAuthFlow? clientCredentials; 32 | 33 | /// Configuration for the OAuth Authorization Code flow. 34 | /// Previously called accessCode in OpenAPI 2.0. 35 | final OAuthFlow? authorizationCode; 36 | 37 | Map toJson() => _$OAuthFlowsToJson(this); 38 | } 39 | -------------------------------------------------------------------------------- /lib/src/models/daemon_message.dart: -------------------------------------------------------------------------------- 1 | import 'package:json_annotation/json_annotation.dart'; 2 | 3 | part 'daemon_message.g.dart'; 4 | 5 | @JsonSerializable() 6 | class DartFrogDaemonMessage { 7 | DartFrogDaemonMessage({ 8 | required this.method, 9 | required this.params, 10 | required this.id, 11 | }); 12 | 13 | factory DartFrogDaemonMessage.fromJson(Map json) => 14 | _$DartFrogDaemonMessageFromJson(json); 15 | 16 | Map toJson() => _$DartFrogDaemonMessageToJson(this); 17 | 18 | static const String startWatchMethod = 'route_configuration.watcherStart'; 19 | 20 | static const String stopWatchMethod = 'route_configuration.watcherStop'; 21 | 22 | final String method; 23 | final DartFrogDaemonMessageParams params; 24 | final String id; 25 | } 26 | 27 | @JsonSerializable() 28 | class DartFrogDaemonMessageParams { 29 | DartFrogDaemonMessageParams({ 30 | this.workingDirectory, 31 | this.watcherId, 32 | }); 33 | 34 | factory DartFrogDaemonMessageParams.fromJson(Map json) => 35 | _$DartFrogDaemonMessageParamsFromJson(json); 36 | 37 | Map toJson() => _$DartFrogDaemonMessageParamsToJson(this); 38 | 39 | final String? workingDirectory; 40 | final String? watcherId; 41 | } 42 | -------------------------------------------------------------------------------- /test/src/extensions/string_extension_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:nenuphar_cli/src/extensions/string_extension.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | void main() { 5 | group('endsWithPathParam', () { 6 | test('route path should return false', () { 7 | // GIVEN 8 | const path = '/'; 9 | 10 | // WHEN 11 | final result = path.endsWithPathParam(); 12 | 13 | // THEN 14 | expect(result, isFalse); 15 | }); 16 | test('path not ending with param should return false', () { 17 | // GIVEN 18 | const path = '/todos'; 19 | 20 | // WHEN 21 | final result = path.endsWithPathParam(); 22 | 23 | // THEN 24 | expect(result, isFalse); 25 | }); 26 | test('path ending with param should return true', () { 27 | // GIVEN 28 | const path = '/todos/{id}'; 29 | 30 | // WHEN 31 | final result = path.endsWithPathParam(); 32 | 33 | // THEN 34 | expect(result, isTrue); 35 | }); 36 | test('path containing param in the middle should return false', () { 37 | // GIVEN 38 | const path = '/todos/{id}/comments'; 39 | 40 | // WHEN 41 | final result = path.endsWithPathParam(); 42 | 43 | // THEN 44 | expect(result, isFalse); 45 | }); 46 | }); 47 | } 48 | -------------------------------------------------------------------------------- /lib/src/models/oauth_flow.dart: -------------------------------------------------------------------------------- 1 | import 'package:json_annotation/json_annotation.dart'; 2 | 3 | part 'oauth_flow.g.dart'; 4 | 5 | /// 6 | /// https://swagger.io/specification/v3/#oauth-flow-object 7 | /// 8 | /// Configuration details for a supported OAuth Flow 9 | /// 10 | @JsonSerializable() 11 | class OAuthFlow { 12 | const OAuthFlow({ 13 | this.authorizationUrl, 14 | this.tokenUrl, 15 | this.refreshUrl, 16 | this.scopes = const {}, 17 | }); 18 | 19 | factory OAuthFlow.fromJson(Map json) => 20 | _$OAuthFlowFromJson(json); 21 | 22 | /// REQUIRED. The authorization URL to be used for this flow. 23 | /// This MUST be in the form of a URL. 24 | /// apply for implicit, authorizationCode type. 25 | final String? authorizationUrl; 26 | 27 | /// REQUIRED. The token URL to be used for this flow. 28 | /// This MUST be in the form of a URL. 29 | /// apply for password, clientCredentials, authorizationCode type. 30 | final String? tokenUrl; 31 | 32 | /// The URL to be used for obtaining refresh tokens. 33 | /// This MUST be in the form of a URL. 34 | final String? refreshUrl; 35 | 36 | /// REQUIRED. The available scopes for the OAuth2 security scheme. 37 | /// A map between the scope name and a short description for it. 38 | /// The map MAY be empty. 39 | final Map scopes; 40 | 41 | Map toJson() => _$OAuthFlowToJson(this); 42 | } 43 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # nenuphar_cli changelog 2 | ## 0.2.2 (2023-09-2) 3 | 4 | * fix `executableName` to show correct executable command in help 5 | 6 | ## 0.2.1 (2023-09-19) 7 | 8 | * ignoring `/docs/` folder in `.pubignore` file 9 | 10 | ## 0.2.0 (2023-09-17) 11 | 12 | * Add `nenuphar watch` command 13 | 14 | ## 0.1.0 (2023-08-22) 15 | 16 | * Add security schemes 17 | 18 | ## 0.0.10 (2023-08-18) 19 | 20 | * Fix tag parsing for root dynamic route 21 | * Pretty print openapi.json file content 22 | * Secure routes path parsing using filesystem instead of String concatenation 23 | * Convert `\` file path separators to `/` API path separators (fixes #22) 24 | * Add some unit tests to secure Windows filesystem style issues (fixes #21 #23) 25 | 26 | ## 0.0.9 (2023-08-15) 27 | 28 | * Add @Allow tag to specify allowed methods 29 | 30 | ## 0.0.8 (2023-08-15) 31 | 32 | * Add methods OPTION, HEAD, PATCH 33 | 34 | ## 0.0.7 (2023-08-13) 35 | 36 | * Add nenuphar.json file for base OpenAPI configuration 37 | 38 | ## 0.0.6 (2023-08-13) 39 | 40 | * Fix unsecure ling in README.md 41 | * Add example 42 | 43 | ## 0.0.5 (2023-08-13) 44 | 45 | * Add Header params 46 | * Add Query params 47 | 48 | ## 0.0.4 (2023-08-12) 49 | 50 | * Tests gen command 51 | 52 | ## 0.0.3 (2023-08-12) 53 | 54 | * Fix documentation pictures + add video 55 | 56 | ## 0.0.2 (2023-08-12) 57 | 58 | * Improve documentation 59 | 60 | ## 0.0.1 (2023-08-11) 61 | 62 | * First version -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to nenuphar_cli 🎨 2 | 3 | Thanks for opening the CONTRIBUTING file. 4 | Nenuphar is open source and will continue to be. 5 | If you want to make a suggestion, open an issue of propose a change, please read the following lines. 6 | 7 | ## Code of conduct 👥 8 | 9 | Before posting anything on this repository, please read the [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) 10 | 11 | ## Open an issue 📝 12 | 13 | For any suggestion, issue or question, please fill an issue 14 | 15 | [https://github.com/PiotrFLEURY/nenuphar_cli/issues/new/choose](https://github.com/PiotrFLEURY/nenuphar_cli/issues/new/choose) 16 | 17 | Please provide the more context as possible in order to plainly understand your purpose and being able to address it. 18 | 19 | ## Propose a change 🏗️ 20 | 21 | > NOTICE 22 | > 23 | > Any change must respect the [conventional commits convention](https://www.conventionalcommits.org/en/v1.0.0/) 24 | 25 | To propose any change please use the following process : 26 | 27 | 1. `Fork` this repository 28 | 2. Create a `new branch` 29 | 3. `Make` your changes 30 | 4. `Create new tests` and `update existing` ones if required 31 | 5. Update the `documentation` 32 | 6. `Commit` the changes on your branch 33 | 7. Propose a `pull request` to the `main` branch and fill the description 34 | 8. Ensure `CI checks` are successful - update your changes following the CI indication if needed 35 | 9. Wait for a `code review` 36 | 10. When you got a `LGTM` your changes are ready to be merged 🎉 37 | -------------------------------------------------------------------------------- /lib/src/models/info.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'info.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | Info _$InfoFromJson(Map json) => Info( 10 | title: json['title'] as String? ?? 'A sample API', 11 | description: json['description'] as String? ?? 'A sample API', 12 | termsOfService: json['termsOfService'] as String? ?? 'http://localhost', 13 | contact: json['contact'] == null 14 | ? const Contact() 15 | : Contact.fromJson(json['contact'] as Map), 16 | license: json['license'] == null 17 | ? const License() 18 | : License.fromJson(json['license'] as Map), 19 | version: json['version'] as String? ?? '0.0.0', 20 | ); 21 | 22 | Map _$InfoToJson(Info instance) { 23 | final val = {}; 24 | 25 | void writeNotNull(String key, dynamic value) { 26 | if (value != null) { 27 | val[key] = value; 28 | } 29 | } 30 | 31 | writeNotNull('title', instance.title); 32 | writeNotNull('description', instance.description); 33 | writeNotNull('termsOfService', instance.termsOfService); 34 | writeNotNull('contact', instance.contact); 35 | writeNotNull('license', instance.license); 36 | writeNotNull('version', instance.version); 37 | return val; 38 | } 39 | -------------------------------------------------------------------------------- /lib/src/models/oauth_flows.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'oauth_flows.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | OAuthFlows _$OAuthFlowsFromJson(Map json) => OAuthFlows( 10 | implicit: json['implicit'] == null 11 | ? null 12 | : OAuthFlow.fromJson(json['implicit'] as Map), 13 | password: json['password'] == null 14 | ? null 15 | : OAuthFlow.fromJson(json['password'] as Map), 16 | clientCredentials: json['clientCredentials'] == null 17 | ? null 18 | : OAuthFlow.fromJson( 19 | json['clientCredentials'] as Map), 20 | authorizationCode: json['authorizationCode'] == null 21 | ? null 22 | : OAuthFlow.fromJson( 23 | json['authorizationCode'] as Map), 24 | ); 25 | 26 | Map _$OAuthFlowsToJson(OAuthFlows instance) { 27 | final val = {}; 28 | 29 | void writeNotNull(String key, dynamic value) { 30 | if (value != null) { 31 | val[key] = value; 32 | } 33 | } 34 | 35 | writeNotNull('implicit', instance.implicit); 36 | writeNotNull('password', instance.password); 37 | writeNotNull('clientCredentials', instance.clientCredentials); 38 | writeNotNull('authorizationCode', instance.authorizationCode); 39 | return val; 40 | } 41 | -------------------------------------------------------------------------------- /lib/src/models/security_scheme.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'security_scheme.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | SecurityScheme _$SecuritySchemeFromJson(Map json) => 10 | SecurityScheme( 11 | type: json['type'] as String, 12 | description: json['description'] as String?, 13 | name: json['name'] as String?, 14 | inLocation: json['in'] as String?, 15 | scheme: json['scheme'] as String?, 16 | bearerFormat: json['bearerFormat'] as String?, 17 | flows: json['flows'] == null 18 | ? null 19 | : OAuthFlows.fromJson(json['flows'] as Map), 20 | openIdConnectUrl: json['openIdConnectUrl'] as String?, 21 | ); 22 | 23 | Map _$SecuritySchemeToJson(SecurityScheme instance) { 24 | final val = { 25 | 'type': instance.type, 26 | }; 27 | 28 | void writeNotNull(String key, dynamic value) { 29 | if (value != null) { 30 | val[key] = value; 31 | } 32 | } 33 | 34 | writeNotNull('description', instance.description); 35 | writeNotNull('name', instance.name); 36 | writeNotNull('in', instance.inLocation); 37 | writeNotNull('scheme', instance.scheme); 38 | writeNotNull('bearerFormat', instance.bearerFormat); 39 | writeNotNull('flows', instance.flows); 40 | writeNotNull('openIdConnectUrl', instance.openIdConnectUrl); 41 | return val; 42 | } 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2023-2024, Piotr FLEURY 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /lib/src/models/daemon_message.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'daemon_message.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | DartFrogDaemonMessage _$DartFrogDaemonMessageFromJson( 10 | Map json) => 11 | DartFrogDaemonMessage( 12 | method: json['method'] as String, 13 | params: DartFrogDaemonMessageParams.fromJson( 14 | json['params'] as Map), 15 | id: json['id'] as String, 16 | ); 17 | 18 | Map _$DartFrogDaemonMessageToJson( 19 | DartFrogDaemonMessage instance) => 20 | { 21 | 'method': instance.method, 22 | 'params': instance.params, 23 | 'id': instance.id, 24 | }; 25 | 26 | DartFrogDaemonMessageParams _$DartFrogDaemonMessageParamsFromJson( 27 | Map json) => 28 | DartFrogDaemonMessageParams( 29 | workingDirectory: json['workingDirectory'] as String?, 30 | watcherId: json['watcherId'] as String?, 31 | ); 32 | 33 | Map _$DartFrogDaemonMessageParamsToJson( 34 | DartFrogDaemonMessageParams instance) { 35 | final val = {}; 36 | 37 | void writeNotNull(String key, dynamic value) { 38 | if (value != null) { 39 | val[key] = value; 40 | } 41 | } 42 | 43 | writeNotNull('workingDirectory', instance.workingDirectory); 44 | writeNotNull('watcherId', instance.watcherId); 45 | return val; 46 | } 47 | -------------------------------------------------------------------------------- /lib/src/models/schema.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'schema.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | Schema _$SchemaFromJson(Map json) => Schema( 10 | type: json['type'] as String?, 11 | format: json['format'] as String?, 12 | requiredProperties: (json['required'] as List?) 13 | ?.map((e) => e as String) 14 | .toList(), 15 | properties: (json['properties'] as Map?)?.map( 16 | (k, e) => MapEntry(k, Schema.fromJson(e as Map)), 17 | ), 18 | ref: json[r'$ref'] as String?, 19 | items: json['items'] == null 20 | ? null 21 | : Schema.fromJson(json['items'] as Map), 22 | minimum: json['minimum'] as int?, 23 | maximum: json['maximum'] as int?, 24 | ); 25 | 26 | Map _$SchemaToJson(Schema instance) { 27 | final val = {}; 28 | 29 | void writeNotNull(String key, dynamic value) { 30 | if (value != null) { 31 | val[key] = value; 32 | } 33 | } 34 | 35 | writeNotNull('type', instance.type); 36 | writeNotNull('format', instance.format); 37 | writeNotNull('required', instance.requiredProperties); 38 | writeNotNull('properties', instance.properties); 39 | writeNotNull(r'$ref', instance.ref); 40 | writeNotNull('items', instance.items); 41 | writeNotNull('minimum', instance.minimum); 42 | writeNotNull('maximum', instance.maximum); 43 | return val; 44 | } 45 | -------------------------------------------------------------------------------- /.github/workflows/nenuphar_cli.yaml: -------------------------------------------------------------------------------- 1 | name: nenuphar_cli 2 | 3 | concurrency: 4 | group: ${{ github.workflow }}-${{ github.ref }} 5 | cancel-in-progress: true 6 | 7 | on: 8 | pull_request: 9 | paths: 10 | - ".github/workflows/nenuphar_cli.yaml" 11 | - "lib/**" 12 | - "test/**" 13 | - "pubspec.yaml" 14 | push: 15 | branches: 16 | - main 17 | paths: 18 | - ".github/workflows/nenuphar_cli.yaml" 19 | - "lib/**" 20 | - "test/**" 21 | - "pubspec.yaml" 22 | 23 | jobs: 24 | semantic-pull-request: 25 | uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/semantic_pull_request.yml@v1 26 | 27 | build: 28 | strategy: 29 | matrix: 30 | os: [ubuntu-latest, windows-latest, macos-latest] 31 | uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/dart_package.yml@v1 32 | with: 33 | min_coverage: 80 34 | runs_on: ${{ matrix.os }} 35 | 36 | spell-check: 37 | uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/spell_check.yml@v1 38 | with: 39 | includes: | 40 | **/*.md 41 | !brick/**/*.md 42 | .*/**/*.md 43 | modified_files_only: false 44 | 45 | verify-version: 46 | runs-on: ubuntu-latest 47 | if: false 48 | steps: 49 | - name: 📚 Git Checkout 50 | uses: actions/checkout@v4 51 | 52 | - name: 🎯 Setup Dart 53 | uses: dart-lang/setup-dart@v1 54 | with: 55 | sdk: "stable" 56 | 57 | - name: 📦 Install Dependencies 58 | run: | 59 | dart pub get 60 | 61 | - name: 🔎 Verify version 62 | run: dart run test --run-skipped -t version-verify 63 | -------------------------------------------------------------------------------- /lib/src/models/parameter.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'parameter.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | Parameter _$ParameterFromJson(Map json) => Parameter( 10 | name: json['name'] as String, 11 | inLocation: $enumDecodeNullable(_$InLocationEnumMap, json['in']) ?? 12 | InLocation.query, 13 | description: json['description'] as String?, 14 | required: json['required'] as bool? ?? false, 15 | deprecated: json['deprecated'] as bool? ?? false, 16 | allowEmptyValue: json['allowEmptyValue'] as bool? ?? false, 17 | schema: json['schema'] == null 18 | ? null 19 | : Schema.fromJson(json['schema'] as Map), 20 | ); 21 | 22 | Map _$ParameterToJson(Parameter instance) { 23 | final val = { 24 | 'name': instance.name, 25 | 'in': _$InLocationEnumMap[instance.inLocation]!, 26 | }; 27 | 28 | void writeNotNull(String key, dynamic value) { 29 | if (value != null) { 30 | val[key] = value; 31 | } 32 | } 33 | 34 | writeNotNull('description', instance.description); 35 | val['required'] = instance.required; 36 | val['deprecated'] = instance.deprecated; 37 | val['allowEmptyValue'] = instance.allowEmptyValue; 38 | writeNotNull('schema', instance.schema); 39 | return val; 40 | } 41 | 42 | const _$InLocationEnumMap = { 43 | InLocation.query: 'query', 44 | InLocation.header: 'header', 45 | InLocation.path: 'path', 46 | InLocation.cookie: 'cookie', 47 | }; 48 | -------------------------------------------------------------------------------- /lib/src/models/daemon_event.dart: -------------------------------------------------------------------------------- 1 | import 'package:json_annotation/json_annotation.dart'; 2 | 3 | part 'daemon_event.g.dart'; 4 | 5 | @JsonSerializable() 6 | class DartFrogDaemonEvent { 7 | DartFrogDaemonEvent({ 8 | this.id, 9 | this.result, 10 | this.event, 11 | this.params, 12 | }); 13 | 14 | factory DartFrogDaemonEvent.fromJson(dynamic json) => 15 | _$DartFrogDaemonEventFromJson(json as Map); 16 | 17 | Map toJson() => _$DartFrogDaemonEventToJson(this); 18 | 19 | static const String eventDaemonReady = 'daemon.ready'; 20 | static const String eventWatcherStarted = 'route_configuration.watcherStart'; 21 | static const String eventWatcherStopped = 'route_configuration.watcherStop'; 22 | static const String eventRouteConfigurationChanged = 23 | 'route_configuration.changed'; 24 | 25 | final String? id; 26 | final DaemonEventResult? result; 27 | final String? event; 28 | final DartFrogDaemonEventParams? params; 29 | } 30 | 31 | @JsonSerializable() 32 | class DartFrogDaemonEventParams { 33 | DartFrogDaemonEventParams({ 34 | this.watcherId, 35 | this.versionId, 36 | this.processId, 37 | }); 38 | 39 | factory DartFrogDaemonEventParams.fromJson(Map json) => 40 | _$DartFrogDaemonEventParamsFromJson(json); 41 | 42 | Map toJson() => _$DartFrogDaemonEventParamsToJson(this); 43 | 44 | final String? watcherId; 45 | final String? versionId; 46 | final int? processId; 47 | } 48 | 49 | @JsonSerializable() 50 | class DaemonEventResult { 51 | DaemonEventResult({ 52 | this.watcherId, 53 | }); 54 | 55 | factory DaemonEventResult.fromJson(Map json) => 56 | _$DaemonEventResultFromJson(json); 57 | 58 | Map toJson() => _$DaemonEventResultToJson(this); 59 | 60 | final String? watcherId; 61 | } 62 | -------------------------------------------------------------------------------- /lib/src/models/openapi.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'openapi.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | OpenApi _$OpenApiFromJson(Map json) => OpenApi( 10 | openapi: json['openapi'] as String? ?? '3.0.3', 11 | info: json['info'] == null 12 | ? const Info() 13 | : Info.fromJson(json['info'] as Map), 14 | externalDocs: json['externalDocs'] == null 15 | ? const ExternalDocumentation() 16 | : ExternalDocumentation.fromJson( 17 | json['externalDocs'] as Map), 18 | servers: (json['servers'] as List?) 19 | ?.map((e) => Server.fromJson(e as Map)) 20 | .toList() ?? 21 | const [Server()], 22 | tags: (json['tags'] as List?) 23 | ?.map((e) => Tag.fromJson(e as Map)) 24 | .toList(), 25 | paths: (json['paths'] as Map?)?.map( 26 | (k, e) => MapEntry(k, Paths.fromJson(e as Map)), 27 | ) ?? 28 | const {}, 29 | components: json['components'] == null 30 | ? null 31 | : Components.fromJson(json['components'] as Map), 32 | ); 33 | 34 | Map _$OpenApiToJson(OpenApi instance) { 35 | final val = { 36 | 'openapi': instance.openapi, 37 | 'info': instance.info, 38 | 'externalDocs': instance.externalDocs, 39 | 'servers': instance.servers, 40 | }; 41 | 42 | void writeNotNull(String key, dynamic value) { 43 | if (value != null) { 44 | val[key] = value; 45 | } 46 | } 47 | 48 | writeNotNull('tags', instance.tags); 49 | writeNotNull('components', instance.components); 50 | val['paths'] = instance.paths; 51 | return val; 52 | } 53 | -------------------------------------------------------------------------------- /lib/src/models/paths.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'paths.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | Paths _$PathsFromJson(Map json) => Paths( 10 | get: json['get'] == null 11 | ? null 12 | : Method.fromJson(json['get'] as Map), 13 | post: json['post'] == null 14 | ? null 15 | : Method.fromJson(json['post'] as Map), 16 | put: json['put'] == null 17 | ? null 18 | : Method.fromJson(json['put'] as Map), 19 | delete: json['delete'] == null 20 | ? null 21 | : Method.fromJson(json['delete'] as Map), 22 | patch: json['patch'] == null 23 | ? null 24 | : Method.fromJson(json['patch'] as Map), 25 | head: json['head'] == null 26 | ? null 27 | : Method.fromJson(json['head'] as Map), 28 | options: json['options'] == null 29 | ? null 30 | : Method.fromJson(json['options'] as Map), 31 | trace: json['trace'] == null 32 | ? null 33 | : Method.fromJson(json['trace'] as Map), 34 | ); 35 | 36 | Map _$PathsToJson(Paths instance) { 37 | final val = {}; 38 | 39 | void writeNotNull(String key, dynamic value) { 40 | if (value != null) { 41 | val[key] = value; 42 | } 43 | } 44 | 45 | writeNotNull('get', instance.get); 46 | writeNotNull('post', instance.post); 47 | writeNotNull('put', instance.put); 48 | writeNotNull('delete', instance.delete); 49 | writeNotNull('patch', instance.patch); 50 | writeNotNull('head', instance.head); 51 | writeNotNull('options', instance.options); 52 | writeNotNull('trace', instance.trace); 53 | return val; 54 | } 55 | -------------------------------------------------------------------------------- /lib/src/commands/watch_command.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | import 'dart:io'; 4 | 5 | import 'package:args/command_runner.dart'; 6 | import 'package:file/file.dart'; 7 | import 'package:mason_logger/mason_logger.dart'; 8 | import 'package:nenuphar_cli/src/helpers/dart_frog_daemon_client.dart'; 9 | 10 | /// {@template sample_command} 11 | /// 12 | /// `nenuphar watch` 13 | /// A [Command] to exemplify a sub command 14 | /// {@endtemplate} 15 | class WatchCommand extends Command { 16 | /// {@macro sample_command} 17 | WatchCommand({ 18 | required Logger logger, 19 | required FileSystem fileSystem, 20 | required Future Function() onRouteChanged, 21 | }) : _logger = logger, 22 | _fileSystem = fileSystem, 23 | _onRouteChanged = onRouteChanged; 24 | 25 | @override 26 | String get description => 'Sub command to watch route modifications'; 27 | 28 | @override 29 | String get name => 'watch'; 30 | 31 | final Logger _logger; 32 | 33 | final FileSystem _fileSystem; 34 | 35 | final Future Function() _onRouteChanged; 36 | 37 | @override 38 | Future run() async { 39 | _logger.info('Starting dart_frog daemon...'); 40 | 41 | final daemon = DartFrogDaemonClient( 42 | fileSystem: _fileSystem, 43 | logger: _logger, 44 | onRouteChanged: _onRouteChanged, 45 | )..start(); 46 | 47 | await daemon.waitForReady(); 48 | 49 | // Start watching route modifications 50 | daemon.startWatching(); 51 | 52 | // Listen for user stdin input command 53 | _logger.info('Listening for user input...'); 54 | 55 | final stdinSouscription = stdin.transform(utf8.decoder).listen((input) { 56 | _logger.info('Received user input: $input'); 57 | if (input.trim() == 'exit') { 58 | daemon.stop(); 59 | } else { 60 | daemon.sendCommand(input); 61 | } 62 | }); 63 | 64 | await daemon.waitForTheEnd(); 65 | 66 | await stdinSouscription.cancel(); 67 | 68 | _logger.info('Ending nenuphar watch...'); 69 | 70 | return ExitCode.success.code; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "image": "ghcr.io/piotrfleury/devcontainer-flutter:latest", 3 | "forwardPorts": [3000], 4 | "customizations": { 5 | // Configure properties specific to VS Code. 6 | "vscode": { 7 | "settings": { 8 | "workbench.colorTheme": "Community Material Theme", 9 | "workbench.iconTheme": "material-icon-theme", 10 | "[dart]": { 11 | "editor.formatOnSave": true, 12 | "editor.formatOnType": true, 13 | "editor.rulers": [ 14 | 80 15 | ], 16 | "editor.selectionHighlight": false, 17 | "editor.suggest.snippetsPreventQuickSuggestions": false, 18 | "editor.suggestSelection": "first", 19 | "editor.tabCompletion": "onlySnippets", 20 | "editor.wordBasedSuggestions": false 21 | }, 22 | "editor.stablePeek": true, 23 | "files.autoSave": "onFocusChange", 24 | "editor.inlineSuggest.enabled": true, 25 | "window.zoomLevel": 1, 26 | "github.copilot.enable": { 27 | "*": true, 28 | "yaml": false, 29 | "plaintext": false, 30 | "markdown": true 31 | } 32 | }, 33 | "extensions": [ 34 | "equinusocio.vsc-community-material-theme", 35 | "equinusocio.vsc-material-theme-icons", 36 | "equinusocio.vsc-material-theme", 37 | "pkief.material-icon-theme", 38 | 39 | "dart-code.dart-code", 40 | "dart-code.flutter", 41 | 42 | "github.copilot", 43 | 44 | "eamodio.gitlens", 45 | "usernamehw.errorlens", 46 | "pflannery.vscode-versionlens", 47 | 48 | "davidanson.vscode-markdownlint" 49 | ] 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /lib/src/models/method.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'method.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | Method _$MethodFromJson(Map json) => Method( 10 | description: json['description'] as String?, 11 | summary: json['summary'] as String?, 12 | operationId: json['operationId'] as String?, 13 | tags: 14 | (json['tags'] as List?)?.map((e) => e as String).toList() ?? 15 | const [], 16 | requestBody: json['requestBody'] == null 17 | ? null 18 | : RequestBody.fromJson(json['requestBody'] as Map), 19 | responses: (json['responses'] as Map?)?.map( 20 | (k, e) => MapEntry( 21 | int.parse(k), ResponseBody.fromJson(e as Map)), 22 | ) ?? 23 | const {}, 24 | parameters: (json['parameters'] as List?) 25 | ?.map((e) => Parameter.fromJson(e as Map)) 26 | .toList(), 27 | security: (json['security'] as List?) 28 | ?.map((e) => (e as Map).map( 29 | (k, e) => MapEntry(k, 30 | (e as List).map((e) => e as String).toList()), 31 | )) 32 | .toList() ?? 33 | const [], 34 | ); 35 | 36 | Map _$MethodToJson(Method instance) { 37 | final val = {}; 38 | 39 | void writeNotNull(String key, dynamic value) { 40 | if (value != null) { 41 | val[key] = value; 42 | } 43 | } 44 | 45 | writeNotNull('description', instance.description); 46 | writeNotNull('summary', instance.summary); 47 | writeNotNull('operationId', instance.operationId); 48 | val['tags'] = instance.tags; 49 | writeNotNull('requestBody', instance.requestBody); 50 | val['responses'] = 51 | instance.responses.map((k, e) => MapEntry(k.toString(), e)); 52 | writeNotNull('parameters', instance.parameters); 53 | val['security'] = instance.security; 54 | return val; 55 | } 56 | -------------------------------------------------------------------------------- /docs/pictures/dart_frog_logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /lib/src/commands/update_command.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:args/command_runner.dart'; 4 | import 'package:mason_logger/mason_logger.dart'; 5 | import 'package:nenuphar_cli/src/command_runner.dart'; 6 | import 'package:nenuphar_cli/src/version.dart'; 7 | import 'package:pub_updater/pub_updater.dart'; 8 | 9 | /// {@template update_command} 10 | /// A command which updates the CLI. 11 | /// {@endtemplate} 12 | class UpdateCommand extends Command { 13 | /// {@macro update_command} 14 | UpdateCommand({ 15 | required Logger logger, 16 | PubUpdater? pubUpdater, 17 | }) : _logger = logger, 18 | _pubUpdater = pubUpdater ?? PubUpdater(); 19 | 20 | final Logger _logger; 21 | final PubUpdater _pubUpdater; 22 | 23 | @override 24 | String get description => 'Update the CLI.'; 25 | 26 | static const String commandName = 'update'; 27 | 28 | @override 29 | String get name => commandName; 30 | 31 | @override 32 | Future run() async { 33 | final updateCheckProgress = _logger.progress('Checking for updates'); 34 | late final String latestVersion; 35 | try { 36 | latestVersion = await _pubUpdater.getLatestVersion(packageName); 37 | } catch (error) { 38 | updateCheckProgress.fail(); 39 | _logger.err('$error'); 40 | return ExitCode.software.code; 41 | } 42 | updateCheckProgress.complete('Checked for updates'); 43 | 44 | final isUpToDate = packageVersion == latestVersion; 45 | if (isUpToDate) { 46 | _logger.info('CLI is already at the latest version.'); 47 | return ExitCode.success.code; 48 | } 49 | 50 | final updateProgress = _logger.progress('Updating to $latestVersion'); 51 | 52 | late final ProcessResult result; 53 | try { 54 | result = await _pubUpdater.update( 55 | packageName: packageName, 56 | versionConstraint: latestVersion, 57 | ); 58 | } catch (error) { 59 | updateProgress.fail(); 60 | _logger.err('$error'); 61 | return ExitCode.software.code; 62 | } 63 | 64 | if (result.exitCode != ExitCode.success.code) { 65 | updateProgress.fail(); 66 | _logger.err('Error updating CLI: ${result.stderr}'); 67 | return ExitCode.software.code; 68 | } 69 | 70 | updateProgress.complete('Updated to $latestVersion'); 71 | 72 | return ExitCode.success.code; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /lib/src/models/daemon_event.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'daemon_event.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | DartFrogDaemonEvent _$DartFrogDaemonEventFromJson(Map json) => 10 | DartFrogDaemonEvent( 11 | id: json['id'] as String?, 12 | result: json['result'] == null 13 | ? null 14 | : DaemonEventResult.fromJson(json['result'] as Map), 15 | event: json['event'] as String?, 16 | params: json['params'] == null 17 | ? null 18 | : DartFrogDaemonEventParams.fromJson( 19 | json['params'] as Map), 20 | ); 21 | 22 | Map _$DartFrogDaemonEventToJson(DartFrogDaemonEvent instance) { 23 | final val = {}; 24 | 25 | void writeNotNull(String key, dynamic value) { 26 | if (value != null) { 27 | val[key] = value; 28 | } 29 | } 30 | 31 | writeNotNull('id', instance.id); 32 | writeNotNull('result', instance.result); 33 | writeNotNull('event', instance.event); 34 | writeNotNull('params', instance.params); 35 | return val; 36 | } 37 | 38 | DartFrogDaemonEventParams _$DartFrogDaemonEventParamsFromJson( 39 | Map json) => 40 | DartFrogDaemonEventParams( 41 | watcherId: json['watcherId'] as String?, 42 | versionId: json['versionId'] as String?, 43 | processId: json['processId'] as int?, 44 | ); 45 | 46 | Map _$DartFrogDaemonEventParamsToJson( 47 | DartFrogDaemonEventParams instance) { 48 | final val = {}; 49 | 50 | void writeNotNull(String key, dynamic value) { 51 | if (value != null) { 52 | val[key] = value; 53 | } 54 | } 55 | 56 | writeNotNull('watcherId', instance.watcherId); 57 | writeNotNull('versionId', instance.versionId); 58 | writeNotNull('processId', instance.processId); 59 | return val; 60 | } 61 | 62 | DaemonEventResult _$DaemonEventResultFromJson(Map json) => 63 | DaemonEventResult( 64 | watcherId: json['watcherId'] as String?, 65 | ); 66 | 67 | Map _$DaemonEventResultToJson(DaemonEventResult instance) { 68 | final val = {}; 69 | 70 | void writeNotNull(String key, dynamic value) { 71 | if (value != null) { 72 | val[key] = value; 73 | } 74 | } 75 | 76 | writeNotNull('watcherId', instance.watcherId); 77 | return val; 78 | } 79 | -------------------------------------------------------------------------------- /example/routes/todos/index.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'dart:io'; 3 | 4 | import 'package:dart_frog/dart_frog.dart'; 5 | import 'package:example/models/todos.dart'; 6 | import 'package:example/services/todo_service.dart'; 7 | 8 | /// 9 | /// The /todos routes 10 | /// 11 | /// @Allow(GET, HEAD) - Allow the GET, HEAD methods 12 | /// 13 | /// @Allow(POST, OPTIONS) - Allow the POST and OPTIONS methods 14 | /// 15 | /// @Header(User-Name) - The user name header 16 | /// 17 | /// @Query(completed) - The completed query parameter 18 | /// 19 | /// @Security(todos_basic_auth) - The basic auth security scheme defined in 20 | /// components/_security.dart 21 | /// 22 | /// @Security(todos_oauth) - The oauth security scheme defined in 23 | /// components/_security.dart 24 | /// 25 | /// @Scope(read:todos) - Read scope 26 | /// @Scope(write:todos) - Write scope 27 | /// 28 | /// 29 | Future onRequest(RequestContext context) async { 30 | final todosService = context.read(); 31 | 32 | switch (context.request.method) { 33 | case HttpMethod.get: 34 | case HttpMethod.head: 35 | String jsonPayload; 36 | // Get the completed query parameter 37 | final completed = context.request.uri.queryParameters['completed']; 38 | if (completed != null) { 39 | final todos = todosService.getTodosByCompleted( 40 | completed: completed == 'true', 41 | ); 42 | jsonPayload = jsonEncode(todos); 43 | } 44 | jsonPayload = jsonEncode(todosService.todos); 45 | 46 | if (context.request.method == HttpMethod.head) { 47 | return Response( 48 | headers: { 49 | HttpHeaders.contentLengthHeader: 50 | utf8.encode(jsonPayload).length.toString(), 51 | }, 52 | ); 53 | } 54 | return Response( 55 | body: jsonPayload, 56 | headers: { 57 | HttpHeaders.contentTypeHeader: 'application/json', 58 | }, 59 | ); 60 | case HttpMethod.post: 61 | final jsonBody = await context.request.body(); 62 | final todo = Todo.fromJson(jsonDecode(jsonBody) as Map); 63 | todosService.add(todo); 64 | return Response(body: jsonEncode(todosService.todos)); 65 | 66 | case HttpMethod.options: 67 | return Response( 68 | statusCode: HttpStatus.noContent, 69 | headers: { 70 | HttpHeaders.allowHeader: [ 71 | HttpMethod.get.value, 72 | HttpMethod.head.value, 73 | HttpMethod.post.value, 74 | HttpMethod.options.value, 75 | ].join(', '), 76 | }, 77 | ); 78 | case HttpMethod.put: 79 | case HttpMethod.patch: 80 | case HttpMethod.delete: 81 | return Response(statusCode: HttpStatus.methodNotAllowed); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /lib/src/models/method.dart: -------------------------------------------------------------------------------- 1 | import 'package:json_annotation/json_annotation.dart'; 2 | import 'package:nenuphar_cli/src/models/models.dart'; 3 | 4 | part 'method.g.dart'; 5 | 6 | /// Describes a single API operation on a path. 7 | @JsonSerializable() 8 | class Method { 9 | const Method({ 10 | this.description, 11 | this.summary, 12 | this.operationId, 13 | this.tags = const [], 14 | this.requestBody, 15 | this.responses = const {}, 16 | this.parameters, 17 | this.security = const [], 18 | }); 19 | 20 | factory Method.fromJson(Map json) => _$MethodFromJson(json); 21 | 22 | /// A verbose explanation of the operation behavior. 23 | /// CommonMark syntax MAY be used for rich text representation. 24 | final String? description; 25 | 26 | /// A short summary of what the operation does. 27 | final String? summary; 28 | 29 | /// Unique string used to identify the operation. The id MUST be unique among 30 | /// all operations described in the API. 31 | /// The operationId value is case-sensitive. 32 | /// Tools and libraries MAY use the operationId to uniquely identify an 33 | /// operation, therefore, it is RECOMMENDED to follow common programming 34 | /// naming conventions. 35 | final String? operationId; 36 | 37 | /// A list of tags for API documentation control. Tags can be used for logical 38 | /// grouping of operations by resources or any other qualifier. 39 | final List tags; 40 | 41 | /// The request body applicable for this operation. The requestBody is only 42 | /// supported in HTTP methods where the HTTP 1.1 specification RFC7231 has 43 | /// explicitly defined semantics for request bodies. In other cases where 44 | /// the HTTP spec is vague, requestBody SHALL be ignored by consumers. 45 | final RequestBody? requestBody; 46 | 47 | /// REQUIRED. The list of possible responses as they are returned from 48 | /// executing this operation. 49 | final Map responses; 50 | 51 | /// A list of parameters that are applicable for this operation. 52 | /// If a parameter is already defined at the Path Item, 53 | /// the new definition will override it but can never remove it. 54 | /// The list MUST NOT include duplicated parameters. 55 | /// A unique parameter is defined by a combination of a name and location. 56 | /// The list can use the Reference Object to link to parameters that are 57 | /// defined at the OpenAPI Object's components/parameters. 58 | final List? parameters; 59 | 60 | /// A declaration of which security mechanisms can be used for this operation. 61 | /// The list of values includes alternative security requirement objects 62 | /// that can be used. Only one of the security requirement objects need to 63 | /// be satisfied to authorize a request. 64 | /// To make security optional, an empty security requirement ({}) can be 65 | /// included in the array. This definition overrides any declared top-level 66 | /// security. To remove a top-level security declaration, an empty array 67 | /// can be used. 68 | final List>> security; 69 | 70 | Map toJson() => _$MethodToJson(this); 71 | } 72 | -------------------------------------------------------------------------------- /lib/src/commands/init_command.dart: -------------------------------------------------------------------------------- 1 | import 'package:args/command_runner.dart'; 2 | import 'package:file/file.dart'; 3 | import 'package:mason_logger/mason_logger.dart'; 4 | import 'package:nenuphar_cli/src/models/models.dart'; 5 | import 'package:nenuphar_cli/src/tooling.dart'; 6 | 7 | /// {@template sample_command} 8 | /// 9 | /// `nenuphar init` 10 | /// A [Command] to exemplify a sub command 11 | /// {@endtemplate} 12 | class InitCommand extends Command { 13 | /// {@macro sample_command} 14 | InitCommand({ 15 | required Logger logger, 16 | required FileSystem fileSystem, 17 | }) : _logger = logger, 18 | _fileSystem = fileSystem { 19 | argParser 20 | ..addOption( 21 | 'url', 22 | abbr: 'u', 23 | help: 'Specify the url of the OpenAPI file', 24 | ) 25 | ..addFlag( 26 | 'override', 27 | abbr: 'o', 28 | negatable: false, 29 | help: 'Override existing index.html file', 30 | ); 31 | } 32 | 33 | final FileSystem _fileSystem; 34 | 35 | @override 36 | String get description => 'Sub command to initialize Swagger index.html'; 37 | 38 | @override 39 | String get name => 'init'; 40 | 41 | final indexHtml = ''' 42 | 43 | 44 | 45 | 46 | 47 | 51 | SwaggerUI 52 | 53 | 54 | 55 |
56 | 57 | 65 | 66 | 67 | '''; 68 | 69 | final Logger _logger; 70 | 71 | @override 72 | Future run() async { 73 | // Write index.html in public/index.html 74 | final file = _fileSystem.file('public/index.html'); 75 | final override = argResults!['override'] as bool? ?? false; 76 | if (!override && file.existsSync()) { 77 | _logger.alert('public/index.html already exists'); 78 | return ExitCode.ioError.code; 79 | } else { 80 | file.createSync(recursive: true); 81 | } 82 | await file.writeAsString( 83 | indexHtml.replaceFirst( 84 | '___OPENAPI_FILE_URL___', 85 | 'http://localhost:8080/openapi.json', 86 | ), 87 | ); 88 | 89 | _logger.info('Generated public/index.html file'); 90 | 91 | final nenupharJson = _fileSystem.file('nenuphar.json'); 92 | if (!override && nenupharJson.existsSync()) { 93 | _logger.alert('nenuphar.json already exists'); 94 | return ExitCode.ioError.code; 95 | } else { 96 | nenupharJson 97 | ..createSync() 98 | ..writeAsStringSync(jsonPrettyEncoder.convert(OpenApi())); 99 | } 100 | 101 | return ExitCode.success.code; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nenuphar - Your OpenAPI generator CLI 2 | 3 | nenuphar logo generated using Microsoft Designer 8 | 9 | [![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link] 10 | [![License: MIT][license_badge]][license_link] 11 | 12 | Nenuphar is a CLI application to generate Swagger UI OpenAPI definition files for your [Dart Frog](https://dartfrog.vgv.dev/) Server. 13 | 14 | https://github.com/PiotrFLEURY/nenuphar_cli/assets/25879276/1590e803-7888-4fa0-8555-2f29f2035bd1 15 | 16 | --- 17 | 18 | # Dart Frog 19 | 20 | 21 | 22 | [Dart Frog](https://dartfrog.vgv.dev/) is a minimalistic backend framework for Dart. 23 | 24 | ## Create a new Dart Frog project 25 | 26 | To create a new Dart Frog backend project, run the following command: 27 | 28 | ```sh 29 | dart pub global activate dart_frog_cli 30 | dart_frog create 31 | ``` 32 | 33 | # Nenuphar CLI 34 | 35 | ## Installation 36 | 37 | ```sh 38 | dart pub global activate nenuphar_cli 39 | ``` 40 | 41 | ## Initialize your project 42 | 43 | First you need to initialize your project by running the following command in the root of your project: 44 | 45 | ```sh 46 | nenuphar init 47 | ``` 48 | 49 | This will create new file `public/index.html`. This file will be served statically by your Dart Frog server to expose your `Swagger UI` documentation. 50 | 51 | ## Generate openapi definition file 52 | 53 | Nenuphar scans your Dart Frog project to generate an OpenAPI definition file. 54 | Each route will generate the CRUD operations documentation for the exposed resource. 55 | 56 | First create a Dart Frog route: 57 | 58 | ```sh 59 | dart_frog new route "/todos" 60 | ``` 61 | 62 | Then generate the OpenAPI definition file 63 | 64 | ```sh 65 | nenuphar gen 66 | ``` 67 | 68 | The openapi specification will be written in the `public/openapi.json` file. 69 | This file is loaded by the `public/index.html` file to display the documentation. 70 | 71 | ## Start your Dart Frog server 72 | 73 | You're now ready to start your Dart Frog server 74 | 75 | ```sh 76 | dart_frog dev 77 | ``` 78 | 79 | Visit http://localhost:8080/index.html to see your documentation. 80 | 81 | ```sh 82 | open http://localhost:8080/index.html 83 | ``` 84 | 85 | ## Enjoy 🎉 86 | 87 | 88 | 89 | __Thanks for using Nenuphar!__ 90 | 91 | # Detailed documentation 92 | 93 | Please visit [https://piotrfleury.github.io/nenuphar_cli/](https://piotrfleury.github.io/nenuphar_cli/) for detailed documentation. 94 | 95 | Generated by the [Very Good CLI][very_good_cli_link] 🤖 96 | 97 | --- 98 | 99 | [license_badge]: https://img.shields.io/badge/license-bsd_3_clause-blue 100 | [license_link]: https://opensource.org/licenses/bsd-3-clause 101 | [very_good_analysis_badge]: https://img.shields.io/badge/style-very_good_analysis-B22C89.svg 102 | [very_good_analysis_link]: https://pub.dev/packages/very_good_analysis 103 | [very_good_cli_link]: https://github.com/VeryGoodOpenSource/very_good_cli 104 | -------------------------------------------------------------------------------- /lib/src/models/security_scheme.dart: -------------------------------------------------------------------------------- 1 | import 'package:json_annotation/json_annotation.dart'; 2 | import 'package:nenuphar_cli/src/models/models.dart'; 3 | 4 | part 'security_scheme.g.dart'; 5 | 6 | /// 7 | /// https://swagger.io/specification/v3/#security-scheme-object 8 | /// 9 | /// Defines a security scheme that can be used by the operations. 10 | /// Supported schemes are HTTP authentication, 11 | /// an API key (either as a header, a cookie parameter or as a query parameter), 12 | /// OAuth2's common flows (implicit, password, client credentials and 13 | /// authorization code) as defined in RFC6749, and OpenID Connect Discovery. 14 | /// 15 | /// Examples: 16 | /// 17 | /// Basic Authentication Sample 18 | /// type: http 19 | /// scheme: basic 20 | /// 21 | /// 22 | /// API Key Sample 23 | /// type: apiKey 24 | /// name: api_key 25 | /// in: header 26 | /// 27 | /// 28 | /// JWT Bearer Sample 29 | /// type: http 30 | /// scheme: bearer 31 | /// bearerFormat: JWT 32 | /// 33 | /// 34 | /// Implicit OAuth2 Sample 35 | /// type: oauth2 36 | /// flows: 37 | /// implicit: 38 | /// authorizationUrl: https://example.com/api/oauth/dialog 39 | /// scopes: 40 | /// write:pets: modify pets in your account 41 | /// read:pets: read your pets 42 | /// 43 | @JsonSerializable() 44 | class SecurityScheme { 45 | const SecurityScheme({ 46 | required this.type, 47 | this.description, 48 | this.name, 49 | this.inLocation, 50 | this.scheme, 51 | this.bearerFormat, 52 | this.flows, 53 | this.openIdConnectUrl, 54 | }); 55 | 56 | factory SecurityScheme.fromJson(Map json) => 57 | _$SecuritySchemeFromJson(json); 58 | 59 | /// REQUIRED. The type of the security scheme. 60 | /// Valid values are "apiKey", "http", "oauth2", "openIdConnect". 61 | /// apply for any type. 62 | final String type; 63 | 64 | /// A short description for security scheme. 65 | /// CommonMark syntax MAY be used for rich text representation. 66 | /// apply for any type. 67 | final String? description; 68 | 69 | /// REQUIRED. The name of the header, query or cookie parameter to be used. 70 | /// apply for apiKey type. 71 | final String? name; 72 | 73 | /// REQUIRED. The location of the API key. 74 | /// Valid values are "query", "header" or "cookie". 75 | /// apply for apiKey type. 76 | @JsonKey(name: 'in') 77 | final String? inLocation; 78 | 79 | /// REQUIRED. The name of the HTTP Authorization scheme to be used 80 | /// in the Authorization header as defined in RFC7235. 81 | /// The values used SHOULD be registered 82 | /// in the IANA Authentication Scheme registry. 83 | /// apply for http type. 84 | final String? scheme; 85 | 86 | /// A hint to the client to identify how the bearer token is formatted. 87 | /// Bearer tokens are usually generated by an authorization server, 88 | /// so this information is primarily for documentation purposes. 89 | /// apply for http type. 90 | final String? bearerFormat; 91 | 92 | /// REQUIRED. An object containing configuration information 93 | /// for the flow types supported. 94 | /// apply for oauth2 type. 95 | final OAuthFlows? flows; 96 | 97 | /// REQUIRED. OpenId Connect URL to discover OAuth2 configuration values. 98 | /// This MUST be in the form of a URL. 99 | /// apply for openIdConnect type. 100 | final String? openIdConnectUrl; 101 | 102 | Map toJson() => _$SecuritySchemeToJson(this); 103 | } 104 | -------------------------------------------------------------------------------- /test/src/commands/init_command_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:file/file.dart'; 2 | import 'package:file/memory.dart'; 3 | import 'package:mason_logger/mason_logger.dart'; 4 | import 'package:mocktail/mocktail.dart'; 5 | import 'package:nenuphar_cli/src/command_runner.dart'; 6 | import 'package:test/test.dart'; 7 | 8 | class _MockLogger extends Mock implements Logger {} 9 | 10 | void main() { 11 | group('init', () { 12 | late Logger logger; 13 | late NenupharCliCommandRunner commandRunner; 14 | late FileSystem memoryFileSystem; 15 | 16 | setUp(() { 17 | logger = _MockLogger(); 18 | memoryFileSystem = MemoryFileSystem(); 19 | commandRunner = NenupharCliCommandRunner( 20 | logger: logger, 21 | fileSystem: memoryFileSystem, 22 | ); 23 | }); 24 | 25 | test('initialize index.html when public dir does not exists', () async { 26 | // GIVEN 27 | final publicDir = memoryFileSystem.directory('public'); 28 | if (publicDir.existsSync()) { 29 | publicDir.deleteSync(recursive: true); 30 | } 31 | 32 | // WHEN 33 | final result = await commandRunner.run(['init']); 34 | 35 | // THEN 36 | expect(result, equals(ExitCode.success.code)); 37 | final indexHtmlFile = memoryFileSystem.file('public/index.html'); 38 | expect(indexHtmlFile.existsSync(), isTrue); 39 | final nenupharJsonFile = memoryFileSystem.file('nenuphar.json'); 40 | expect(nenupharJsonFile.existsSync(), isTrue); 41 | }); 42 | 43 | test('initialize index.html when public dir already exists', () async { 44 | // GIVEN 45 | final publicDir = memoryFileSystem.directory('public'); 46 | if (!publicDir.existsSync()) { 47 | publicDir.createSync(); 48 | } 49 | 50 | // WHEN 51 | final result = await commandRunner.run(['init']); 52 | 53 | // THEN 54 | expect(result, equals(ExitCode.success.code)); 55 | final indexHtmlFile = memoryFileSystem.file('public/index.html'); 56 | expect(indexHtmlFile.existsSync(), isTrue); 57 | final nenupharJsonFile = memoryFileSystem.file('nenuphar.json'); 58 | expect(nenupharJsonFile.existsSync(), isTrue); 59 | }); 60 | 61 | test('fail if public/index.html already exists', () async { 62 | // GIVEN 63 | final index = memoryFileSystem.file('public/index.html'); 64 | if (!index.existsSync()) { 65 | index.createSync(recursive: true); 66 | } 67 | 68 | // WHEN 69 | final result = await commandRunner.run(['init']); 70 | 71 | // THEN 72 | expect(result, equals(ExitCode.ioError.code)); 73 | }); 74 | 75 | test('fail if nenuphar.json already exists', () async { 76 | // GIVEN 77 | final nenupharJsonFile = memoryFileSystem.file('nenuphar.json'); 78 | if (!nenupharJsonFile.existsSync()) { 79 | nenupharJsonFile.createSync(recursive: true); 80 | } 81 | 82 | // WHEN 83 | final result = await commandRunner.run(['init']); 84 | 85 | // THEN 86 | expect(result, equals(ExitCode.ioError.code)); 87 | }); 88 | 89 | test('succeed if public/index.html already exists with -o flag', () async { 90 | // GIVEN 91 | final index = memoryFileSystem.file('public/index.html'); 92 | if (!index.existsSync()) { 93 | index.createSync(recursive: true); 94 | } 95 | final nenupharJsonFile = memoryFileSystem.file('nenuphar.json'); 96 | if (!nenupharJsonFile.existsSync()) { 97 | nenupharJsonFile.createSync(recursive: true); 98 | } 99 | 100 | // WHEN 101 | final result = await commandRunner.run(['init', '-o']); 102 | 103 | // THEN 104 | expect(result, equals(ExitCode.success.code)); 105 | final indexHtmlFile = memoryFileSystem.file('public/index.html'); 106 | expect(indexHtmlFile.existsSync(), isTrue); 107 | expect(nenupharJsonFile.existsSync(), isTrue); 108 | }); 109 | }); 110 | } 111 | -------------------------------------------------------------------------------- /lib/src/command_runner.dart: -------------------------------------------------------------------------------- 1 | import 'package:args/args.dart'; 2 | import 'package:args/command_runner.dart'; 3 | import 'package:cli_completion/cli_completion.dart'; 4 | import 'package:file/file.dart'; 5 | import 'package:file/local.dart'; 6 | import 'package:mason_logger/mason_logger.dart'; 7 | import 'package:nenuphar_cli/src/commands/commands.dart'; 8 | import 'package:nenuphar_cli/src/version.dart'; 9 | import 'package:pub_updater/pub_updater.dart'; 10 | 11 | const executableName = 'nenuphar'; 12 | const packageName = 'nenuphar_cli'; 13 | const description = 'A Very Good Project created by Very Good CLI.'; 14 | 15 | /// {@template nenuphar_cli_command_runner} 16 | /// A [CommandRunner] for the CLI. 17 | /// 18 | /// ``` 19 | /// $ nenuphar --version 20 | /// ``` 21 | /// {@endtemplate} 22 | class NenupharCliCommandRunner extends CompletionCommandRunner { 23 | /// {@macro nenuphar_cli_command_runner} 24 | NenupharCliCommandRunner({ 25 | Logger? logger, 26 | PubUpdater? pubUpdater, 27 | FileSystem? fileSystem, 28 | }) : _logger = logger ?? Logger(), 29 | _pubUpdater = pubUpdater ?? PubUpdater(), 30 | _fileSystem = fileSystem ?? const LocalFileSystem(), 31 | super(executableName, description) { 32 | // Add root options and flags 33 | argParser 34 | ..addFlag( 35 | 'version', 36 | abbr: 'v', 37 | negatable: false, 38 | help: 'Print the current version.', 39 | ) 40 | ..addFlag( 41 | 'verbose', 42 | help: 'Noisy logging, including all shell commands executed.', 43 | ); 44 | 45 | // Add sub commands 46 | addCommand(UpdateCommand(logger: _logger, pubUpdater: _pubUpdater)); 47 | addCommand( 48 | GenCommand( 49 | logger: _logger, 50 | fileSystem: _fileSystem, 51 | ), 52 | ); 53 | addCommand( 54 | InitCommand( 55 | logger: _logger, 56 | fileSystem: _fileSystem, 57 | ), 58 | ); 59 | addCommand( 60 | WatchCommand( 61 | logger: _logger, 62 | fileSystem: _fileSystem, 63 | onRouteChanged: () async => run(['gen']), 64 | ), 65 | ); 66 | } 67 | 68 | @override 69 | void printUsage() => _logger.info(usage); 70 | 71 | final Logger _logger; 72 | final PubUpdater _pubUpdater; 73 | final FileSystem _fileSystem; 74 | 75 | @override 76 | Future run(Iterable args) async { 77 | try { 78 | final topLevelResults = parse(args); 79 | if (topLevelResults['verbose'] == true) { 80 | _logger.level = Level.verbose; 81 | } 82 | return await runCommand(topLevelResults) ?? ExitCode.success.code; 83 | } on FormatException catch (e, stackTrace) { 84 | // On format errors, show the commands error message, root usage and 85 | // exit with an error code 86 | _logger 87 | ..err(e.message) 88 | ..err('$stackTrace') 89 | ..info('') 90 | ..info(usage); 91 | return ExitCode.usage.code; 92 | } on UsageException catch (e) { 93 | // On usage errors, show the commands usage message and 94 | // exit with an error code 95 | _logger 96 | ..err(e.message) 97 | ..info('') 98 | ..info(e.usage); 99 | return ExitCode.usage.code; 100 | } 101 | } 102 | 103 | @override 104 | Future runCommand(ArgResults topLevelResults) async { 105 | // Fast track completion command 106 | if (topLevelResults.command?.name == 'completion') { 107 | await super.runCommand(topLevelResults); 108 | return ExitCode.success.code; 109 | } 110 | 111 | // Verbose logs 112 | _logger 113 | ..detail('Argument information:') 114 | ..detail(' Top level options:'); 115 | for (final option in topLevelResults.options) { 116 | if (topLevelResults.wasParsed(option)) { 117 | _logger.detail(' - $option: ${topLevelResults[option]}'); 118 | } 119 | } 120 | if (topLevelResults.command != null) { 121 | final commandResult = topLevelResults.command!; 122 | _logger 123 | ..detail(' Command: ${commandResult.name}') 124 | ..detail(' Command options:'); 125 | for (final option in commandResult.options) { 126 | if (commandResult.wasParsed(option)) { 127 | _logger.detail(' - $option: ${commandResult[option]}'); 128 | } 129 | } 130 | } 131 | 132 | // Run the command or show version 133 | final int? exitCode; 134 | if (topLevelResults['version'] == true) { 135 | _logger.info(packageVersion); 136 | exitCode = ExitCode.success.code; 137 | } else { 138 | exitCode = await super.runCommand(topLevelResults); 139 | } 140 | 141 | // Check for updates 142 | if (topLevelResults.command?.name != UpdateCommand.commandName) { 143 | await _checkForUpdates(); 144 | } 145 | 146 | return exitCode; 147 | } 148 | 149 | /// Checks if the current version (set by the build runner on the 150 | /// version.dart file) is the most recent one. If not, show a prompt to the 151 | /// user. 152 | Future _checkForUpdates() async { 153 | try { 154 | final latestVersion = await _pubUpdater.getLatestVersion(packageName); 155 | final isUpToDate = packageVersion == latestVersion; 156 | if (!isUpToDate) { 157 | _logger 158 | ..info('') 159 | ..info( 160 | ''' 161 | ${lightYellow.wrap('Update available!')} ${lightCyan.wrap(packageVersion)} \u2192 ${lightCyan.wrap(latestVersion)} 162 | Run ${lightCyan.wrap('$executableName update')} to update''', 163 | ); 164 | } 165 | } catch (_) {} 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | piotr.fleury@gmail.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /test/src/command_runner_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:args/command_runner.dart'; 4 | import 'package:cli_completion/cli_completion.dart'; 5 | import 'package:mason_logger/mason_logger.dart'; 6 | import 'package:mocktail/mocktail.dart'; 7 | import 'package:nenuphar_cli/src/command_runner.dart'; 8 | import 'package:nenuphar_cli/src/version.dart'; 9 | import 'package:pub_updater/pub_updater.dart'; 10 | import 'package:test/test.dart'; 11 | 12 | class _MockLogger extends Mock implements Logger {} 13 | 14 | class _MockProgress extends Mock implements Progress {} 15 | 16 | class _MockPubUpdater extends Mock implements PubUpdater {} 17 | 18 | const latestVersion = '0.0.0'; 19 | 20 | final updatePrompt = ''' 21 | ${lightYellow.wrap('Update available!')} ${lightCyan.wrap(packageVersion)} \u2192 ${lightCyan.wrap(latestVersion)} 22 | Run ${lightCyan.wrap('$executableName update')} to update'''; 23 | 24 | void main() { 25 | group('NenupharCliCommandRunner', () { 26 | late PubUpdater pubUpdater; 27 | late Logger logger; 28 | late NenupharCliCommandRunner commandRunner; 29 | 30 | setUp(() { 31 | pubUpdater = _MockPubUpdater(); 32 | 33 | when( 34 | () => pubUpdater.getLatestVersion(any()), 35 | ).thenAnswer((_) async => packageVersion); 36 | 37 | logger = _MockLogger(); 38 | 39 | commandRunner = NenupharCliCommandRunner( 40 | logger: logger, 41 | pubUpdater: pubUpdater, 42 | ); 43 | }); 44 | 45 | test('shows update message when newer version exists', () async { 46 | when( 47 | () => pubUpdater.getLatestVersion(any()), 48 | ).thenAnswer((_) async => latestVersion); 49 | 50 | final result = await commandRunner.run(['--version']); 51 | expect(result, equals(ExitCode.success.code)); 52 | verify(() => logger.info(updatePrompt)).called(1); 53 | }); 54 | 55 | test( 56 | 'Does not show update message when the shell calls the ' 57 | 'completion command', 58 | () async { 59 | when( 60 | () => pubUpdater.getLatestVersion(any()), 61 | ).thenAnswer((_) async => latestVersion); 62 | 63 | final result = await commandRunner.run(['completion']); 64 | expect(result, equals(ExitCode.success.code)); 65 | verifyNever(() => logger.info(updatePrompt)); 66 | }, 67 | ); 68 | 69 | test('does not show update message when using update command', () async { 70 | when( 71 | () => pubUpdater.getLatestVersion(any()), 72 | ).thenAnswer((_) async => latestVersion); 73 | when( 74 | () => pubUpdater.update( 75 | packageName: packageName, 76 | versionConstraint: any(named: 'versionConstraint'), 77 | ), 78 | ).thenAnswer( 79 | (_) async => ProcessResult(0, ExitCode.success.code, null, null), 80 | ); 81 | when( 82 | () => pubUpdater.isUpToDate( 83 | packageName: any(named: 'packageName'), 84 | currentVersion: any(named: 'currentVersion'), 85 | ), 86 | ).thenAnswer((_) async => true); 87 | 88 | final progress = _MockProgress(); 89 | final progressLogs = []; 90 | when(() => progress.complete(any())).thenAnswer((_) { 91 | final message = _.positionalArguments.elementAt(0) as String?; 92 | if (message != null) progressLogs.add(message); 93 | }); 94 | when(() => logger.progress(any())).thenReturn(progress); 95 | 96 | final result = await commandRunner.run(['update']); 97 | expect(result, equals(ExitCode.success.code)); 98 | verifyNever(() => logger.info(updatePrompt)); 99 | }); 100 | 101 | test('can be instantiated without an explicit analytics/logger instance', 102 | () { 103 | final commandRunner = NenupharCliCommandRunner(); 104 | expect(commandRunner, isNotNull); 105 | expect(commandRunner, isA>()); 106 | }); 107 | 108 | test('handles FormatException', () async { 109 | const exception = FormatException('oops!'); 110 | var isFirstInvocation = true; 111 | when(() => logger.info(any())).thenAnswer((_) { 112 | if (isFirstInvocation) { 113 | isFirstInvocation = false; 114 | throw exception; 115 | } 116 | }); 117 | final result = await commandRunner.run(['--version']); 118 | expect(result, equals(ExitCode.usage.code)); 119 | verify(() => logger.err(exception.message)).called(1); 120 | verify(() => logger.info(commandRunner.usage)).called(1); 121 | }); 122 | 123 | test('handles UsageException', () async { 124 | final exception = UsageException('oops!', 'exception usage'); 125 | var isFirstInvocation = true; 126 | when(() => logger.info(any())).thenAnswer((_) { 127 | if (isFirstInvocation) { 128 | isFirstInvocation = false; 129 | throw exception; 130 | } 131 | }); 132 | final result = await commandRunner.run(['--version']); 133 | expect(result, equals(ExitCode.usage.code)); 134 | verify(() => logger.err(exception.message)).called(1); 135 | verify(() => logger.info('exception usage')).called(1); 136 | }); 137 | 138 | group('--version', () { 139 | test('outputs current version', () async { 140 | final result = await commandRunner.run(['--version']); 141 | expect(result, equals(ExitCode.success.code)); 142 | verify(() => logger.info(packageVersion)).called(1); 143 | }); 144 | }); 145 | 146 | group('--verbose', () { 147 | test('enables verbose logging', () async { 148 | final result = await commandRunner.run(['--verbose']); 149 | expect(result, equals(ExitCode.success.code)); 150 | 151 | verify(() => logger.detail('Argument information:')).called(1); 152 | verify(() => logger.detail(' Top level options:')).called(1); 153 | verify(() => logger.detail(' - verbose: true')).called(1); 154 | verifyNever(() => logger.detail(' Command options:')); 155 | }); 156 | }); 157 | }); 158 | } 159 | -------------------------------------------------------------------------------- /lib/src/helpers/dart_frog_daemon_client.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | import 'dart:io'; 4 | 5 | import 'package:file/file.dart'; 6 | import 'package:mason_logger/mason_logger.dart'; 7 | import 'package:nenuphar_cli/src/models/daemon_event.dart'; 8 | import 'package:nenuphar_cli/src/models/daemon_message.dart'; 9 | 10 | /// 11 | /// Helper class to communicate with the dart_frog daemon 12 | /// 13 | class DartFrogDaemonClient { 14 | DartFrogDaemonClient({ 15 | required FileSystem fileSystem, 16 | required Logger logger, 17 | required Future Function() onRouteChanged, 18 | }) : _fileSystem = fileSystem, 19 | _logger = logger, 20 | _onRouteChanged = onRouteChanged; 21 | 22 | final FileSystem _fileSystem; 23 | 24 | final Logger _logger; 25 | 26 | /// Callback called when the route configuration changed 27 | final Future Function() _onRouteChanged; 28 | 29 | /// The daemon process instance used to communicate with the daemon 30 | late Process _daemonProcess; 31 | 32 | /// Daemon process stdout subscription 33 | late StreamSubscription _stdoutSouscription; 34 | 35 | /// Daemon process stderr subscription 36 | late StreamSubscription _stderrSouscription; 37 | 38 | /// Completer used to know when the daemon is ready 39 | final Completer _daemonReady = Completer(); 40 | 41 | /// Completer used to know when the watcher is started 42 | final Completer _watcherId = Completer(); 43 | 44 | /// Completer used to know when the daemon should stop 45 | final Completer _end = Completer(); 46 | 47 | /// Boolean used to know if the daemon is generating the openapi file 48 | /// This is used to avoid multiple generation at the same time 49 | bool _generating = false; 50 | 51 | /// Starts the daemon process 52 | void start() { 53 | Process.start( 54 | 'dart_frog', 55 | ['daemon'], 56 | workingDirectory: _fileSystem.currentDirectory.path, 57 | ).then((process) { 58 | _daemonProcess = process; 59 | _logger.info('dart_frog daemon PID: ${_daemonProcess.pid}'); 60 | _listenDaemonOutput(); 61 | }); 62 | } 63 | 64 | /// Stops the daemon process and clean up resources 65 | Future stop() async { 66 | await stopWatching(); 67 | 68 | await _stdoutSouscription.cancel(); 69 | await _stderrSouscription.cancel(); 70 | 71 | _end.complete(true); 72 | 73 | return _daemonProcess.kill(); 74 | } 75 | 76 | /// This method is called to wait until the daemon ends 77 | Future waitForTheEnd() async => _end.future; 78 | 79 | /// This method is called to wait until the daemon is ready 80 | Future waitForReady() async => _daemonReady.future; 81 | 82 | /// Listen for daemon output using stdout and stderr 83 | void _listenDaemonOutput() { 84 | _logger.info('Listening for dart_frog daemon output...'); 85 | // souscription cancelled in the run method 86 | // ignore: cancel_subscriptions 87 | _stdoutSouscription = 88 | _daemonProcess.stdout.transform(utf8.decoder).listen(_onEvents); 89 | // souscription cancelled in the run method 90 | // ignore: cancel_subscriptions 91 | _stderrSouscription = 92 | _daemonProcess.stderr.transform(utf8.decoder).listen(_onErrors); 93 | } 94 | 95 | /// Send `watcherStart` command to the daemon 96 | void startWatching() { 97 | _logger.info('Starting to watch route modifications...'); 98 | final startWatchMessage = DartFrogDaemonMessage( 99 | method: DartFrogDaemonMessage.startWatchMethod, 100 | params: DartFrogDaemonMessageParams( 101 | workingDirectory: _fileSystem.currentDirectory.path, 102 | ), 103 | id: pid.toString(), 104 | ); 105 | 106 | final jsonCommand = jsonEncode([startWatchMessage.toJson()]); 107 | 108 | sendCommand(jsonCommand); 109 | } 110 | 111 | /// Send `watcherStop` command to the daemon 112 | Future stopWatching() async { 113 | _logger.info('Stopping to watch route modifications...'); 114 | 115 | if (!_watcherId.isCompleted) { 116 | _logger.info('No watcher started'); 117 | return; 118 | } 119 | 120 | final watcherId = await _watcherId.future; 121 | 122 | final stopWatchMessage = DartFrogDaemonMessage( 123 | method: DartFrogDaemonMessage.stopWatchMethod, 124 | params: DartFrogDaemonMessageParams( 125 | watcherId: watcherId, 126 | ), 127 | id: pid.toString(), 128 | ); 129 | 130 | final jsonCommand = jsonEncode([stopWatchMessage.toJson()]); 131 | 132 | sendCommand(jsonCommand); 133 | } 134 | 135 | /// Sends a command to the daemon 136 | void sendCommand(String command) { 137 | _logger.info('Sending command: $command'); 138 | _daemonProcess.stdin.writeln(command.trim()); 139 | _daemonProcess.stdin.flush(); 140 | } 141 | 142 | /// Logs any error received from the daemon 143 | void _onErrors(String error) { 144 | _logger.err( 145 | 'Received error: $error', 146 | style: (m) => red.wrap(styleBold.wrap(m)), 147 | ); 148 | } 149 | 150 | /// Handle RAW events String received from the daemon 151 | Future _onEvents(String events) async { 152 | events.split('\n').where((line) => line.isNotEmpty).forEach(_onEventLine); 153 | } 154 | 155 | /// Handle a single event String received from the daemon 156 | void _onEventLine(String line) { 157 | _logger.info('Received event [RAW]: $line'); 158 | final eventList = jsonDecode(line.trim()) as List; 159 | eventList.map(DartFrogDaemonEvent.fromJson).forEach(_onEvent); 160 | } 161 | 162 | /// Handle a single deserialized event received from the daemon 163 | Future _onEvent(DartFrogDaemonEvent event) async { 164 | _logger.info( 165 | 'Received event: ${event.event}', 166 | style: (m) => green.wrap(styleBold.wrap(m)), 167 | ); 168 | 169 | switch (event.event) { 170 | case DartFrogDaemonEvent.eventDaemonReady: 171 | _daemonReady.complete(true); 172 | case DartFrogDaemonEvent.eventWatcherStarted: 173 | _watcherId.complete(event.params?.watcherId); 174 | case DartFrogDaemonEvent.eventWatcherStopped: 175 | _logger.info('Watcher stopped'); 176 | case DartFrogDaemonEvent.eventRouteConfigurationChanged: 177 | _logger.info( 178 | 'Route configuration changed, regenerating openapi file...', 179 | ); 180 | if (_generating) { 181 | _logger.info('Already generating, skipping...'); 182 | return; 183 | } 184 | _generating = true; 185 | await _onRouteChanged(); 186 | _generating = false; 187 | } 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /test/src/commands/update_command_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:mason_logger/mason_logger.dart'; 4 | import 'package:mocktail/mocktail.dart'; 5 | import 'package:nenuphar_cli/src/command_runner.dart'; 6 | import 'package:nenuphar_cli/src/commands/commands.dart'; 7 | import 'package:nenuphar_cli/src/version.dart'; 8 | import 'package:pub_updater/pub_updater.dart'; 9 | import 'package:test/test.dart'; 10 | 11 | class _MockLogger extends Mock implements Logger {} 12 | 13 | class _MockProgress extends Mock implements Progress {} 14 | 15 | class _MockPubUpdater extends Mock implements PubUpdater {} 16 | 17 | void main() { 18 | const latestVersion = '0.0.0'; 19 | 20 | group('update', () { 21 | late PubUpdater pubUpdater; 22 | late Logger logger; 23 | late NenupharCliCommandRunner commandRunner; 24 | 25 | setUp(() { 26 | final progress = _MockProgress(); 27 | final progressLogs = []; 28 | pubUpdater = _MockPubUpdater(); 29 | logger = _MockLogger(); 30 | commandRunner = NenupharCliCommandRunner( 31 | logger: logger, 32 | pubUpdater: pubUpdater, 33 | ); 34 | 35 | when( 36 | () => pubUpdater.getLatestVersion(any()), 37 | ).thenAnswer((_) async => packageVersion); 38 | when( 39 | () => pubUpdater.update( 40 | packageName: packageName, 41 | versionConstraint: latestVersion, 42 | ), 43 | ).thenAnswer( 44 | (_) async => ProcessResult(0, ExitCode.success.code, null, null), 45 | ); 46 | when( 47 | () => pubUpdater.isUpToDate( 48 | packageName: any(named: 'packageName'), 49 | currentVersion: any(named: 'currentVersion'), 50 | ), 51 | ).thenAnswer((_) async => true); 52 | when(() => progress.complete(any())).thenAnswer((_) { 53 | final message = _.positionalArguments.elementAt(0) as String?; 54 | if (message != null) progressLogs.add(message); 55 | }); 56 | when(() => logger.progress(any())).thenReturn(progress); 57 | }); 58 | 59 | test('can be instantiated without a pub updater', () { 60 | final command = UpdateCommand(logger: logger); 61 | expect(command, isNotNull); 62 | }); 63 | 64 | test( 65 | 'handles pub latest version query errors', 66 | () async { 67 | when( 68 | () => pubUpdater.getLatestVersion(any()), 69 | ).thenThrow(Exception('oops')); 70 | final result = await commandRunner.run(['update']); 71 | expect(result, equals(ExitCode.software.code)); 72 | verify(() => logger.progress('Checking for updates')).called(1); 73 | verify(() => logger.err('Exception: oops')); 74 | verifyNever( 75 | () => pubUpdater.update( 76 | packageName: any(named: 'packageName'), 77 | versionConstraint: any(named: 'versionConstraint'), 78 | ), 79 | ); 80 | }, 81 | ); 82 | 83 | test( 84 | 'handles pub update errors', 85 | () async { 86 | when( 87 | () => pubUpdater.getLatestVersion(any()), 88 | ).thenAnswer((_) async => latestVersion); 89 | when( 90 | () => pubUpdater.update( 91 | packageName: any(named: 'packageName'), 92 | versionConstraint: any(named: 'versionConstraint'), 93 | ), 94 | ).thenThrow(Exception('oops')); 95 | final result = await commandRunner.run(['update']); 96 | expect(result, equals(ExitCode.software.code)); 97 | verify(() => logger.progress('Checking for updates')).called(1); 98 | verify(() => logger.err('Exception: oops')); 99 | verify( 100 | () => pubUpdater.update( 101 | packageName: any(named: 'packageName'), 102 | versionConstraint: any(named: 'versionConstraint'), 103 | ), 104 | ).called(1); 105 | }, 106 | ); 107 | 108 | test('handles pub update process errors', () async { 109 | const error = 'Oh no! Installing this is not possible right now!'; 110 | 111 | when( 112 | () => pubUpdater.getLatestVersion(any()), 113 | ).thenAnswer((_) async => latestVersion); 114 | 115 | when( 116 | () => pubUpdater.update( 117 | packageName: any(named: 'packageName'), 118 | versionConstraint: any(named: 'versionConstraint'), 119 | ), 120 | ).thenAnswer((_) async => ProcessResult(0, 1, null, error)); 121 | 122 | final result = await commandRunner.run(['update']); 123 | 124 | expect(result, equals(ExitCode.software.code)); 125 | verify(() => logger.progress('Checking for updates')).called(1); 126 | verify(() => logger.err('Error updating CLI: $error')); 127 | verify( 128 | () => pubUpdater.update( 129 | packageName: any(named: 'packageName'), 130 | versionConstraint: any(named: 'versionConstraint'), 131 | ), 132 | ).called(1); 133 | }); 134 | 135 | test( 136 | 'updates when newer version exists', 137 | () async { 138 | when( 139 | () => pubUpdater.getLatestVersion(any()), 140 | ).thenAnswer((_) async => latestVersion); 141 | when( 142 | () => pubUpdater.update( 143 | packageName: any(named: 'packageName'), 144 | versionConstraint: any(named: 'versionConstraint'), 145 | ), 146 | ).thenAnswer( 147 | (_) async => ProcessResult(0, ExitCode.success.code, null, null), 148 | ); 149 | when(() => logger.progress(any())).thenReturn(_MockProgress()); 150 | final result = await commandRunner.run(['update']); 151 | expect(result, equals(ExitCode.success.code)); 152 | verify(() => logger.progress('Checking for updates')).called(1); 153 | verify(() => logger.progress('Updating to $latestVersion')).called(1); 154 | verify( 155 | () => pubUpdater.update( 156 | packageName: packageName, 157 | versionConstraint: latestVersion, 158 | ), 159 | ).called(1); 160 | }, 161 | ); 162 | 163 | test( 164 | 'does not update when already on latest version', 165 | () async { 166 | when( 167 | () => pubUpdater.getLatestVersion(any()), 168 | ).thenAnswer((_) async => packageVersion); 169 | when(() => logger.progress(any())).thenReturn(_MockProgress()); 170 | final result = await commandRunner.run(['update']); 171 | expect(result, equals(ExitCode.success.code)); 172 | verify( 173 | () => logger.info('CLI is already at the latest version.'), 174 | ).called(1); 175 | verifyNever(() => logger.progress('Updating to $latestVersion')); 176 | verifyNever( 177 | () => pubUpdater.update( 178 | packageName: any(named: 'packageName'), 179 | versionConstraint: any(named: 'versionConstraint'), 180 | ), 181 | ); 182 | }, 183 | ); 184 | }); 185 | } 186 | -------------------------------------------------------------------------------- /example/public/openapi.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.0.3", 3 | "info": { 4 | "title": "Nenuphar API Documentation", 5 | "description": "Example of Todo API documented with Nenuphar", 6 | "termsOfService": "https://tosdr.org/", 7 | "contact": { 8 | "name": "Piotr FLEURY", 9 | "url": "https://github.com/PiotrFLEURY/nenuphar_cli", 10 | "email": "piotr.fleury@gmail.com" 11 | }, 12 | "license": { 13 | "name": "BSD 3-Clause License", 14 | "url": "https://github.com/PiotrFLEURY/nenuphar_cli/blob/main/LICENSE" 15 | }, 16 | "version": "1.0.0" 17 | }, 18 | "externalDocs": { 19 | "description": "Nenuphar CLI detailed documentation", 20 | "url": "https://piotrfleury.github.io/nenuphar_cli/" 21 | }, 22 | "servers": [ 23 | { 24 | "url": "http://localhost:8080", 25 | "description": "Local server" 26 | } 27 | ], 28 | "tags": [ 29 | { 30 | "name": "todos", 31 | "description": "Operations about todos" 32 | } 33 | ], 34 | "components": { 35 | "schemas": { 36 | "todos": { 37 | "type": "object", 38 | "properties": { 39 | "id": { 40 | "type": "integer", 41 | "format": "int64" 42 | }, 43 | "name": { 44 | "type": "string" 45 | }, 46 | "completed": { 47 | "type": "boolean" 48 | } 49 | } 50 | } 51 | }, 52 | "securitySchemes": { 53 | "todos_basic_auth": { 54 | "type": "http", 55 | "scheme": "basic" 56 | }, 57 | "todos_api_key": { 58 | "type": "apiKey", 59 | "name": "api_key", 60 | "in": "header" 61 | }, 62 | "todos_oauth": { 63 | "type": "oauth2", 64 | "flows": { 65 | "implicit": { 66 | "authorizationUrl": "https://nenuphar.io/oauth/authorize", 67 | "scopes": { 68 | "write:todos": "modify todos", 69 | "read:todos": "read your todos" 70 | } 71 | } 72 | } 73 | } 74 | } 75 | }, 76 | "paths": { 77 | "/todos": { 78 | "get": { 79 | "tags": [ 80 | "todos" 81 | ], 82 | "responses": { 83 | "200": { 84 | "description": "A list of todos.", 85 | "headers": {}, 86 | "content": { 87 | "application/json": { 88 | "schema": { 89 | "type": "array", 90 | "items": { 91 | "$ref": "#/components/schemas/todos" 92 | } 93 | } 94 | } 95 | } 96 | } 97 | }, 98 | "parameters": [ 99 | { 100 | "name": "User-Name", 101 | "in": "header", 102 | "required": false, 103 | "deprecated": false, 104 | "allowEmptyValue": false, 105 | "schema": { 106 | "type": "string" 107 | } 108 | }, 109 | { 110 | "name": "completed", 111 | "in": "query", 112 | "required": false, 113 | "deprecated": false, 114 | "allowEmptyValue": false, 115 | "schema": { 116 | "type": "string" 117 | } 118 | } 119 | ], 120 | "security": [ 121 | { 122 | "todos_basic_auth": [ 123 | "read:todos", 124 | "write:todos" 125 | ] 126 | }, 127 | { 128 | "todos_oauth": [ 129 | "read:todos", 130 | "write:todos" 131 | ] 132 | } 133 | ] 134 | }, 135 | "post": { 136 | "tags": [ 137 | "todos" 138 | ], 139 | "requestBody": { 140 | "description": "", 141 | "required": false, 142 | "content": { 143 | "application/json": { 144 | "schema": { 145 | "$ref": "#/components/schemas/todos" 146 | } 147 | } 148 | } 149 | }, 150 | "responses": { 151 | "201": { 152 | "description": "Created todos.", 153 | "headers": {}, 154 | "content": {} 155 | } 156 | }, 157 | "parameters": [ 158 | { 159 | "name": "User-Name", 160 | "in": "header", 161 | "required": false, 162 | "deprecated": false, 163 | "allowEmptyValue": false, 164 | "schema": { 165 | "type": "string" 166 | } 167 | }, 168 | { 169 | "name": "completed", 170 | "in": "query", 171 | "required": false, 172 | "deprecated": false, 173 | "allowEmptyValue": false, 174 | "schema": { 175 | "type": "string" 176 | } 177 | } 178 | ], 179 | "security": [ 180 | { 181 | "todos_basic_auth": [ 182 | "read:todos", 183 | "write:todos" 184 | ] 185 | }, 186 | { 187 | "todos_oauth": [ 188 | "read:todos", 189 | "write:todos" 190 | ] 191 | } 192 | ] 193 | }, 194 | "head": { 195 | "tags": [ 196 | "todos" 197 | ], 198 | "responses": { 199 | "200": { 200 | "description": "Meta informations about todos.", 201 | "headers": {}, 202 | "content": {} 203 | } 204 | }, 205 | "parameters": [ 206 | { 207 | "name": "User-Name", 208 | "in": "header", 209 | "required": false, 210 | "deprecated": false, 211 | "allowEmptyValue": false, 212 | "schema": { 213 | "type": "string" 214 | } 215 | } 216 | ], 217 | "security": [ 218 | { 219 | "todos_basic_auth": [ 220 | "read:todos", 221 | "write:todos" 222 | ] 223 | }, 224 | { 225 | "todos_oauth": [ 226 | "read:todos", 227 | "write:todos" 228 | ] 229 | } 230 | ] 231 | }, 232 | "options": { 233 | "tags": [ 234 | "todos" 235 | ], 236 | "responses": { 237 | "204": { 238 | "description": "Allowed HTTP methods for /todos", 239 | "headers": { 240 | "Allow": { 241 | "description": "Allowed HTTP methods for /todos", 242 | "schema": { 243 | "type": "string" 244 | } 245 | } 246 | }, 247 | "content": {} 248 | } 249 | }, 250 | "parameters": [ 251 | { 252 | "name": "User-Name", 253 | "in": "header", 254 | "required": false, 255 | "deprecated": false, 256 | "allowEmptyValue": false, 257 | "schema": { 258 | "type": "string" 259 | } 260 | } 261 | ], 262 | "security": [ 263 | { 264 | "todos_basic_auth": [ 265 | "read:todos", 266 | "write:todos" 267 | ] 268 | }, 269 | { 270 | "todos_oauth": [ 271 | "read:todos", 272 | "write:todos" 273 | ] 274 | } 275 | ] 276 | } 277 | }, 278 | "/todos/{id}": { 279 | "get": { 280 | "tags": [ 281 | "todos" 282 | ], 283 | "responses": { 284 | "200": { 285 | "description": "A todos.", 286 | "headers": {}, 287 | "content": { 288 | "application/json": { 289 | "schema": { 290 | "$ref": "#/components/schemas/todos" 291 | } 292 | } 293 | } 294 | } 295 | }, 296 | "parameters": [ 297 | { 298 | "name": "id", 299 | "in": "path", 300 | "required": true, 301 | "deprecated": false, 302 | "allowEmptyValue": false, 303 | "schema": { 304 | "type": "string" 305 | } 306 | }, 307 | { 308 | "name": "User-Name", 309 | "in": "header", 310 | "required": false, 311 | "deprecated": false, 312 | "allowEmptyValue": false, 313 | "schema": { 314 | "type": "string" 315 | } 316 | } 317 | ], 318 | "security": [ 319 | { 320 | "todos_api_key": [] 321 | } 322 | ] 323 | }, 324 | "delete": { 325 | "tags": [ 326 | "todos" 327 | ], 328 | "responses": { 329 | "204": { 330 | "description": "Deleted", 331 | "headers": {}, 332 | "content": {} 333 | } 334 | }, 335 | "parameters": [ 336 | { 337 | "name": "id", 338 | "in": "path", 339 | "required": true, 340 | "deprecated": false, 341 | "allowEmptyValue": false, 342 | "schema": { 343 | "type": "string" 344 | } 345 | }, 346 | { 347 | "name": "User-Name", 348 | "in": "header", 349 | "required": false, 350 | "deprecated": false, 351 | "allowEmptyValue": false, 352 | "schema": { 353 | "type": "string" 354 | } 355 | } 356 | ], 357 | "security": [ 358 | { 359 | "todos_api_key": [] 360 | } 361 | ] 362 | } 363 | } 364 | } 365 | } -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Nenuphar Documentation 2 | 3 | Nenuphar is a tool to generate [Swagger OpenAPI](https://swagger.io/specification/) documentation for [Dart Frog](https://dartfrog.vgv.dev/) projects. 4 | 5 | Generate your OpenAPI documentation in few steps: 6 | * [Create a new Dart Frog project](#create-a-new-dart-frog-project) 7 | * [Initialize nenuphar](#initialize-nenuphar) 8 | * [Generate openapi definition file](#generate-openapi-definition-file) 9 | * [Enjoy 🎉](#enjoy-) 10 | 11 | # Table of contents 12 | 13 | 14 | [Dart Frog](#dart-frog) 15 | - [Create a new Dart Frog project](#create-a-new-dart-frog-project) 16 | 17 | [Swagger OpenAPI](#swagger-openapi) 18 | - [OpenAPI Specification](#openapi-specification) 19 | - [Swagger UI](#swagger-ui) 20 | 21 | [Nenuphar](#nenuphar) 22 | - [Installation](#installation) 23 | - [Initialize nenuphar](#initialize-nenuphar) 24 | - [Index.html file](#indexhtml-file) 25 | - [nenuphar.json file](#nenupharjson-file) 26 | - [init failures](#init-failures) 27 | - [init command available options](#init-command-available-options) 28 | - [Generate openapi definition file](#generate-openapi-definition-file) 29 | - [gen failures](#gen-failures) 30 | - [gen command available options](#gen-command-available-options) 31 | - [Watch mode](#watch-mode) 32 | - [Declare resources components](#resources-components) 33 | - [Declare a resource](#declare-a-resource) 34 | - [Allowed methods](#allowed-methods) 35 | - [Parameters](#parameters) 36 | - [Header](#header) 37 | - [Query](#query) 38 | - [Path](#path) 39 | - [Body](#body) 40 | - [Security](#security) 41 | - [Declare security schemes](#declare-security-schemes) 42 | - [Use security in path](#use-security-in-path) 43 | - [Use scopes in path](#use-scopes-in-path) 44 | - [Start your Dart Frog server](#start-your-dart-frog-server) 45 | - [Enjoy 🎉](#enjoy-) 46 | 47 | # Dart Frog 48 | 49 | 50 | 51 | [Dart Frog](https://dartfrog.vgv.dev/) is a minimalistic backend framework for Dart made by [VGVentures](https://vgventures.fr/). 52 | 53 | ## Create a new Dart Frog project 54 | 55 | To create a new Dart Frog backend project, run the following command: 56 | 57 | ```sh 58 | dart pub global activate dart_frog_cli 59 | dart_frog create 60 | ``` 61 | 62 | # Swagger OpenAPI 63 | 64 | 65 | 66 | Swagger is a set of open source tools built around the OpenAPI Specification that can help you design, build, document and consume REST APIs. 67 | 68 | ## OpenAPI Specification 69 | 70 | The OpenAPI Specification (OAS) defines a standard, language-agnostic interface to RESTful APIs which allows both humans and computers to discover and understand the capabilities of the service without access to source code, documentation, or through network traffic inspection. 71 | 72 | Please visit [https://swagger.io/specification/v3/](https://swagger.io/specification/v3/) for more information. 73 | 74 | ## Swagger UI 75 | 76 | Swagger UI allows anyone — be it your development team or your end consumers — to visualize and interact with the API’s resources without having any of the implementation logic in place. It’s automatically generated from your OpenAPI (formerly known as Swagger) Specification, with the visual documentation making it easy for back end implementation and client side consumption. 77 | 78 | 79 | 80 | Please visit [https://swagger.io/tools/swagger-ui/](https://swagger.io/tools/swagger-ui/) for more information. 81 | 82 | # Nenuphar 83 | 84 | 85 | 86 | ## Installation 87 | 88 | ```sh 89 | dart pub global activate nenuphar_cli 90 | ``` 91 | 92 | ## Initialize nenuphar 93 | 94 | First you need to initialize your project by running the following command in the root of your project: 95 | 96 | ```sh 97 | nenuphar init 98 | ``` 99 | 100 | ### Index.html file 101 | 102 | Init command will create new file `public/index.html`. This file will be served statically by your Dart Frog server to expose your `Swagger UI` documentation. 103 | 104 | ### nenuphar.json file 105 | 106 | Init command will create new file `nenuphar.json`. This file contains the base configuration of your openapi documentation. 107 | 108 | Feel free to edit this file with your own configuration. 109 | 110 | ```json 111 | { 112 | "openapi": "3.0.3", 113 | "info": { 114 | "title": "A sample API", 115 | "description": "A sample API", 116 | "termsOfService": "http://localhost", 117 | "contact": { 118 | "name": "none", 119 | "url": "http://localhost", 120 | "email": "none@api.com" 121 | }, 122 | "license": { 123 | "name": "", 124 | "url": "" 125 | }, 126 | "version": "0.0.0" 127 | }, 128 | "externalDocs": { 129 | "description": "", 130 | "url": "http://localhost/" 131 | }, 132 | "servers": [ 133 | { 134 | "url": "http://localhost:8080", 135 | "description": "Local server" 136 | } 137 | ], 138 | "paths": {} 139 | } 140 | ``` 141 | 142 | ### Init failures 143 | 144 | __Error: index.html already exists__ 145 | 146 | init command will fail if the `public/index.html` file already exists and the `--override` option is not set to `true`. 147 | 148 | __Error: nenuphar.json already exists__ 149 | 150 | init command will fail if the `nenuphar.json` file already exists and the `--override` option is not set to `true`. 151 | 152 | ### init command available options 153 | 154 | | Option | Abbr | Description | Default value | 155 | | --- | --- | --- | --- | 156 | | --url | -u | Url to the openapi definition file | http://localhost:8080/public/openapi.json | 157 | | --override | -o | Override existing file | false | 158 | 159 | ## Generate openapi definition file 160 | 161 | Nenuphar scans your Dart Frog project to generate an OpenAPI definition file. 162 | Each route will generate the CRUD operations documentation for the exposed resource. 163 | 164 | > NOTICE: 165 | > 166 | > nenuphar ignores the `/` route by default. 167 | 168 | First create a Dart Frog route: 169 | 170 | ```sh 171 | dart_frog new route "/todos" 172 | ``` 173 | 174 | Then generate the OpenAPI definition file 175 | 176 | ```sh 177 | nenuphar gen 178 | ``` 179 | 180 | ### gen failures 181 | 182 | __Error: Init not called__ 183 | 184 | gen command can fail if you didn't call the `nenuphar init` command before. 185 | 186 | ### gen command available options 187 | 188 | | Option | Abbr | Description | Default value | 189 | | --- | --- | --- | --- | 190 | | --output | -o | Output file | public/openapi.json | 191 | 192 | The openapi specification will be written in the `public/openapi.json` file. 193 | This file is loaded by the `public/index.html` file to display the documentation. 194 | 195 | > NOTICE: 196 | > 197 | > You need to run the nenuphar gen command each time you update your API. 198 | 199 | ### Watch mode 200 | 201 | nenuphar can watch your Dart Frog project to automatically generate the openapi definition file each time you update a route. 202 | 203 | ```sh 204 | nenuphar watch 205 | ``` 206 | 207 | This command will use the dart_frog daemon to watch your route modifications using the command `dart_frog daemon` 208 | 209 | ## Resources components 210 | 211 | To declare any resource component, you need to create a json file in the `components/` folder using the same name as the resource. 212 | 213 | For example, if you want to declare a `Todo` resource for the `/todos` path, you need to create a `components/todos.json` file. 214 | 215 | ### Declare a resource 216 | 217 | ```json 218 | { 219 | "type": "object", 220 | "properties": { 221 | "id": { 222 | "type": "integer", 223 | "format": "int64" 224 | }, 225 | "name": { 226 | "type": "string" 227 | }, 228 | "completed": { 229 | "type": "boolean" 230 | } 231 | } 232 | } 233 | ``` 234 | 235 | See [OpenAPI schema object specification](https://swagger.io/specification/#schema-object) for more information. 236 | 237 | ## Allowed methods 238 | 239 | By default, nenuphar generates the documentation for the following methods: 240 | 241 | * OPTIONS 242 | * GET 243 | * HEAD 244 | * POST 245 | * PUT 246 | * PATCH 247 | * DELETE 248 | 249 | You can override this behavior by adding the `@Allow` tag to your documentation comment. This tag will only allow the specified methods. 250 | 251 | The example below will only allow the `GET` and `POST` methods. 252 | 253 | ```dart 254 | /// @Allow(GET, POST) 255 | Future onRequest(RequestContext context) async { 256 | // ... 257 | } 258 | ``` 259 | 260 | ## Parameters 261 | 262 | ### Header 263 | 264 | Nenuphar searches a specific documentation comment in your Dart Frog route to generate the header parameters. 265 | 266 | Add the `@Header` tag to your documentation comment to generate the header parameter. 267 | 268 | The name of the parameter is the value of the `@Header` tag. 269 | 270 | ```dart 271 | /// @Header(Authorization) 272 | Future onRequest(RequestContext context) async { 273 | // ... 274 | } 275 | ``` 276 | 277 | ### Query 278 | 279 | Nenuphar searches a specific documentation comment in your Dart Frog route to generate the query parameters. 280 | 281 | Add the `@Query` tag to your documentation comment to generate the header parameter. 282 | 283 | The name of the parameter is the value of the `@Query` tag. 284 | 285 | ```dart 286 | /// @Query(completed) 287 | Future onRequest(RequestContext context) async { 288 | // ... 289 | } 290 | ``` 291 | 292 | ### Path 293 | 294 | Path parameters are automatically detected by nenuphar using the Dart Frog [Dynamic routes system](https://dartfrog.vgv.dev/docs/basics/routes#dynamic-routes-) 295 | 296 | ### Body 297 | 298 | Body parameters are generated using the [Resource components](#resources-components) declared in the `components/` folder. 299 | 300 | ## Security 301 | 302 | If your API is secured, you can declare the security schemes and use them in your paths. 303 | 304 | ### Declare security schemes 305 | 306 | To declare a security scheme, you need to create a `_security.json` file in the `components/` with the appropriate content. 307 | 308 | Supported security schemes are basic, apiKey and oauth2. 309 | 310 | ```json 311 | { 312 | "todos_basic_auth": { 313 | "type": "http", 314 | "scheme": "basic" 315 | }, 316 | "todos_api_key": { 317 | "type": "apiKey", 318 | "name": "api_key", 319 | "in": "header" 320 | }, 321 | "todos_oauth": { 322 | "type": "oauth2", 323 | "flows": { 324 | "implicit": { 325 | "authorizationUrl": "https://nenuphar.io/oauth/authorize", 326 | "scopes": { 327 | "write:todos": "modify todos", 328 | "read:todos": "read your todos" 329 | } 330 | } 331 | } 332 | } 333 | } 334 | ``` 335 | 336 | ### Use security in path 337 | 338 | To use a security scheme in a path, you need to add the `@Security` tag to your documentation comment. 339 | 340 | The name of the security scheme is the value of the `@Security` tag. 341 | 342 | ```dart 343 | /// @Security(todos_basic_auth) 344 | Future onRequest(RequestContext context) async { 345 | // ... 346 | } 347 | ``` 348 | 349 | ### Use scopes in path 350 | 351 | To use a scope in a path, you need to add the `@Scope` tag to your documentation comment. 352 | 353 | The name of the scope is the value of the `@Scope` tag. 354 | 355 | ```dart 356 | /// @Security(todos_oauth) 357 | /// @Scope(read:todos) 358 | Future onRequest(RequestContext context) async { 359 | // ... 360 | } 361 | ``` 362 | 363 | ## Start your Dart Frog server 364 | 365 | You're now ready to start your Dart Frog server 366 | 367 | ```sh 368 | dart_frog dev 369 | ``` 370 | 371 | Visit [http://localhost:8080/index.html](http://localhost:8080/index.html) to see your documentation. 372 | 373 | ```sh 374 | open http://localhost:8080/index.html 375 | ``` 376 | 377 | ## Enjoy 🎉 378 | 379 | 380 | 381 | __Thanks for using Nenuphar!__ -------------------------------------------------------------------------------- /test/src/commands/gen_command_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:file/file.dart'; 4 | import 'package:file/memory.dart'; 5 | import 'package:mason_logger/mason_logger.dart'; 6 | import 'package:mocktail/mocktail.dart'; 7 | import 'package:nenuphar_cli/src/command_runner.dart'; 8 | import 'package:nenuphar_cli/src/models/openapi.dart'; 9 | import 'package:nenuphar_cli/src/models/parameter.dart'; 10 | import 'package:test/test.dart'; 11 | 12 | class _MockLogger extends Mock implements Logger {} 13 | 14 | void main() { 15 | group('gen', () { 16 | late Logger logger; 17 | late NenupharCliCommandRunner commandRunner; 18 | late FileSystem memoryFileSystem; 19 | 20 | setUp(() { 21 | logger = _MockLogger(); 22 | memoryFileSystem = MemoryFileSystem(); 23 | commandRunner = NenupharCliCommandRunner( 24 | logger: logger, 25 | fileSystem: memoryFileSystem, 26 | ); 27 | }); 28 | 29 | test('Fail if nenuphar.json does not exists', () async { 30 | // GIVEN 31 | final nenupharJson = memoryFileSystem.file('nenuphar.json'); 32 | if (nenupharJson.existsSync()) { 33 | nenupharJson.deleteSync(); 34 | } 35 | 36 | memoryFileSystem.file('/routes/index.dart').createSync(recursive: true); 37 | 38 | // WHEN 39 | final result = await commandRunner.run(['gen']); 40 | 41 | // THEN 42 | expect(result, equals(ExitCode.usage.code)); 43 | }); 44 | 45 | test('Contains no operation if only / route exists', () async { 46 | // GIVEN 47 | final publicDir = memoryFileSystem.directory('public'); 48 | if (!publicDir.existsSync()) { 49 | publicDir.createSync(); 50 | } 51 | memoryFileSystem.file('nenuphar.json') 52 | ..createSync() 53 | ..writeAsStringSync( 54 | const JsonEncoder.withIndent(' ').convert(OpenApi()), 55 | ); 56 | 57 | memoryFileSystem.file('/routes/index.dart').createSync(recursive: true); 58 | 59 | // WHEN 60 | final result = await commandRunner.run(['gen']); 61 | 62 | // THEN 63 | expect(result, equals(ExitCode.success.code)); 64 | final openApiFile = memoryFileSystem.file('/public/openapi.json'); 65 | expect(openApiFile.existsSync(), isTrue); 66 | final openApi = OpenApi.fromJson( 67 | jsonDecode( 68 | openApiFile.readAsStringSync(), 69 | ) as Map, 70 | ); 71 | expect(openApi.tags, isEmpty); 72 | expect(openApi.paths, isEmpty); 73 | expect(openApi.components?.schemas, isEmpty); 74 | }); 75 | 76 | test( 77 | 'Contains OPTION HEAD GET POST PUT PATCH for /todos route (no components)', 78 | () async { 79 | // GIVEN 80 | final publicDir = memoryFileSystem.directory('public'); 81 | if (!publicDir.existsSync()) { 82 | publicDir.createSync(); 83 | } 84 | memoryFileSystem.file('nenuphar.json') 85 | ..createSync() 86 | ..writeAsStringSync( 87 | const JsonEncoder.withIndent(' ').convert(OpenApi()), 88 | ); 89 | 90 | memoryFileSystem.file('/routes/index.dart').createSync(recursive: true); 91 | memoryFileSystem.file('/routes/todos.dart').createSync(recursive: true); 92 | 93 | // WHEN 94 | final result = await commandRunner.run(['gen']); 95 | 96 | // THEN 97 | expect(result, equals(ExitCode.success.code)); 98 | final openApiFile = memoryFileSystem.file('/public/openapi.json'); 99 | expect(openApiFile.existsSync(), isTrue); 100 | final openApiJsonString = openApiFile.readAsStringSync(); 101 | final openApi = OpenApi.fromJson( 102 | jsonDecode( 103 | openApiJsonString, 104 | ) as Map, 105 | ); 106 | expect(openApi.tags, isNotEmpty); 107 | expect(openApi.tags![0].name, equals('todos')); 108 | 109 | expect(openApi.paths, isNotEmpty); 110 | // Ensure Windows style filesystem does not propagage \\ in path list 111 | expect(openApi.paths.keys.any((path) => path.contains(r'\')), false); 112 | expect(openApi.paths['/todos']?.delete, isNotNull); 113 | expect(openApi.paths['/todos']?.get, isNotNull); 114 | expect(openApi.paths['/todos']?.head, isNotNull); 115 | expect(openApi.paths['/todos']?.options, isNotNull); 116 | expect(openApi.paths['/todos']?.patch, isNotNull); 117 | expect(openApi.paths['/todos']?.post, isNotNull); 118 | expect(openApi.paths['/todos']?.put, isNotNull); 119 | expect(openApi.paths['/todos']?.trace, isNull); 120 | 121 | expect(openApi.components?.schemas, isEmpty); 122 | }); 123 | 124 | test('Generates header params if @Header tag exists', () async { 125 | // GIVEN 126 | final publicDir = memoryFileSystem.directory('public'); 127 | if (!publicDir.existsSync()) { 128 | publicDir.createSync(); 129 | } 130 | memoryFileSystem.file('nenuphar.json') 131 | ..createSync() 132 | ..writeAsStringSync( 133 | const JsonEncoder.withIndent(' ').convert(OpenApi()), 134 | ); 135 | 136 | memoryFileSystem.file('/routes/index.dart').createSync(recursive: true); 137 | 138 | const todosFileContent = ''' 139 | import 'dart:convert'; 140 | import 'dart:io'; 141 | 142 | import 'package:dart_frog/dart_frog.dart'; 143 | 144 | /// @Header(Authorization) 145 | Future onRequest(RequestContext context) async { 146 | return Response(statusCode: HttpStatus.ok); 147 | } 148 | '''; 149 | 150 | memoryFileSystem.file('/routes/todos.dart') 151 | ..createSync(recursive: true) 152 | ..writeAsStringSync(todosFileContent); 153 | 154 | // WHEN 155 | final result = await commandRunner.run(['gen']); 156 | 157 | // THEN 158 | expect(result, equals(ExitCode.success.code)); 159 | final openApiFile = memoryFileSystem.file('/public/openapi.json'); 160 | expect(openApiFile.existsSync(), isTrue); 161 | final openApi = OpenApi.fromJson( 162 | jsonDecode( 163 | openApiFile.readAsStringSync(), 164 | ) as Map, 165 | ); 166 | 167 | expect(openApi.paths, isNotEmpty); 168 | expect(openApi.paths['/todos']?.get, isNotNull); 169 | expect(openApi.paths['/todos']?.post, isNotNull); 170 | expect(openApi.paths['/todos']?.put, isNotNull); 171 | 172 | expect(openApi.paths['/todos']?.get?.parameters, isNotEmpty); 173 | expect( 174 | openApi.paths['/todos']?.get?.parameters?[0].name, 175 | equals('Authorization'), 176 | ); 177 | expect( 178 | openApi.paths['/todos']?.get?.parameters?[0].inLocation, 179 | equals(InLocation.header), 180 | ); 181 | expect( 182 | openApi.paths['/todos']?.get?.parameters?[0].required, 183 | equals(false), 184 | ); 185 | }); 186 | 187 | test('Generates query params if @Query tag exists', () async { 188 | // GIVEN 189 | final publicDir = memoryFileSystem.directory('public'); 190 | if (!publicDir.existsSync()) { 191 | publicDir.createSync(); 192 | } 193 | memoryFileSystem.file('nenuphar.json') 194 | ..createSync() 195 | ..writeAsStringSync( 196 | const JsonEncoder.withIndent(' ').convert(OpenApi()), 197 | ); 198 | 199 | memoryFileSystem.file('/routes/index.dart').createSync(recursive: true); 200 | 201 | const todosFileContent = ''' 202 | import 'dart:convert'; 203 | import 'dart:io'; 204 | 205 | import 'package:dart_frog/dart_frog.dart'; 206 | 207 | /// @Query(completed) 208 | Future onRequest(RequestContext context) async { 209 | return Response(statusCode: HttpStatus.ok); 210 | } 211 | '''; 212 | 213 | memoryFileSystem.file('/routes/todos.dart') 214 | ..createSync(recursive: true) 215 | ..writeAsStringSync(todosFileContent); 216 | 217 | // WHEN 218 | final result = await commandRunner.run(['gen']); 219 | 220 | // THEN 221 | expect(result, equals(ExitCode.success.code)); 222 | final openApiFile = memoryFileSystem.file('/public/openapi.json'); 223 | expect(openApiFile.existsSync(), isTrue); 224 | final openApi = OpenApi.fromJson( 225 | jsonDecode( 226 | openApiFile.readAsStringSync(), 227 | ) as Map, 228 | ); 229 | 230 | expect(openApi.paths, isNotEmpty); 231 | expect(openApi.paths['/todos']?.get, isNotNull); 232 | expect(openApi.paths['/todos']?.post, isNotNull); 233 | expect(openApi.paths['/todos']?.put, isNotNull); 234 | 235 | expect(openApi.paths['/todos']?.get?.parameters, isNotEmpty); 236 | expect( 237 | openApi.paths['/todos']?.get?.parameters?[0].name, 238 | equals('completed'), 239 | ); 240 | expect( 241 | openApi.paths['/todos']?.get?.parameters?[0].inLocation, 242 | equals(InLocation.query), 243 | ); 244 | expect( 245 | openApi.paths['/todos']?.get?.parameters?[0].required, 246 | equals(false), 247 | ); 248 | }); 249 | 250 | test('Generates only allowed methods if @Allow tag exists', () async { 251 | // GIVEN 252 | final publicDir = memoryFileSystem.directory('public'); 253 | if (!publicDir.existsSync()) { 254 | publicDir.createSync(); 255 | } 256 | memoryFileSystem.file('nenuphar.json') 257 | ..createSync() 258 | ..writeAsStringSync( 259 | const JsonEncoder.withIndent(' ').convert(OpenApi()), 260 | ); 261 | 262 | memoryFileSystem.file('/routes/index.dart').createSync(recursive: true); 263 | 264 | const todosFileContent = ''' 265 | import 'dart:convert'; 266 | import 'dart:io'; 267 | 268 | import 'package:dart_frog/dart_frog.dart'; 269 | 270 | /// @Allow(GET, post, Put, DeLeTe) 271 | Future onRequest(RequestContext context) async { 272 | return Response(statusCode: HttpStatus.ok); 273 | } 274 | '''; 275 | 276 | memoryFileSystem.file('/routes/todos.dart') 277 | ..createSync(recursive: true) 278 | ..writeAsStringSync(todosFileContent); 279 | 280 | // WHEN 281 | final result = await commandRunner.run(['gen']); 282 | 283 | // THEN 284 | expect(result, equals(ExitCode.success.code)); 285 | final openApiFile = memoryFileSystem.file('/public/openapi.json'); 286 | expect(openApiFile.existsSync(), isTrue); 287 | final openApi = OpenApi.fromJson( 288 | jsonDecode( 289 | openApiFile.readAsStringSync(), 290 | ) as Map, 291 | ); 292 | 293 | expect(openApi.paths, isNotEmpty); 294 | expect(openApi.paths['/todos']?.get, isNotNull); 295 | expect(openApi.paths['/todos']?.post, isNotNull); 296 | expect(openApi.paths['/todos']?.put, isNotNull); 297 | expect(openApi.paths['/todos']?.delete, isNotNull); 298 | expect(openApi.paths['/todos']?.head, isNull); 299 | expect(openApi.paths['/todos']?.options, isNull); 300 | expect(openApi.paths['/todos']?.patch, isNull); 301 | expect(openApi.paths['/todos']?.trace, isNull); 302 | }); 303 | 304 | test( 305 | 'Contains OPTION GET HEAD POST PUT PATCH for /todos route (with components)', 306 | () async { 307 | // GIVEN 308 | final publicDir = memoryFileSystem.directory('public'); 309 | if (!publicDir.existsSync()) { 310 | publicDir.createSync(); 311 | } 312 | memoryFileSystem.file('nenuphar.json') 313 | ..createSync() 314 | ..writeAsStringSync( 315 | const JsonEncoder.withIndent(' ').convert(OpenApi()), 316 | ); 317 | 318 | memoryFileSystem.file('/routes/index.dart').createSync(recursive: true); 319 | memoryFileSystem.file('/routes/todos.dart').createSync(recursive: true); 320 | 321 | const componentJson = ''' 322 | { 323 | "type": "object", 324 | "properties": { 325 | "title": { 326 | "type": "string" 327 | }, 328 | "completed": { 329 | "type": "boolean" 330 | } 331 | } 332 | } 333 | '''; 334 | memoryFileSystem.file('/components/todos.json') 335 | ..createSync(recursive: true) 336 | ..writeAsStringSync(componentJson); 337 | 338 | // WHEN 339 | final result = await commandRunner.run(['gen']); 340 | 341 | // THEN 342 | expect(result, equals(ExitCode.success.code)); 343 | final openApiFile = memoryFileSystem.file('/public/openapi.json'); 344 | expect(openApiFile.existsSync(), isTrue); 345 | final openApi = OpenApi.fromJson( 346 | jsonDecode( 347 | openApiFile.readAsStringSync(), 348 | ) as Map, 349 | ); 350 | expect(openApi.tags, isNotEmpty); 351 | expect(openApi.tags![0].name, equals('todos')); 352 | 353 | expect(openApi.paths, isNotEmpty); 354 | expect(openApi.paths['/todos']?.delete, isNotNull); 355 | expect(openApi.paths['/todos']?.get, isNotNull); 356 | expect(openApi.paths['/todos']?.head, isNotNull); 357 | expect(openApi.paths['/todos']?.options, isNotNull); 358 | expect(openApi.paths['/todos']?.patch, isNotNull); 359 | expect(openApi.paths['/todos']?.post, isNotNull); 360 | expect(openApi.paths['/todos']?.put, isNotNull); 361 | expect(openApi.paths['/todos']?.trace, isNull); 362 | 363 | expect(openApi.components?.schemas, isNotEmpty); 364 | expect(openApi.components?.schemas['todos'], isNotNull); 365 | }); 366 | 367 | test( 368 | 'Contains OPTION GET HEAD POST DELETE for /todos/[title] route (with components)', 369 | () async { 370 | // GIVEN 371 | final publicDir = memoryFileSystem.directory('public'); 372 | if (!publicDir.existsSync()) { 373 | publicDir.createSync(); 374 | } 375 | memoryFileSystem.file('nenuphar.json') 376 | ..createSync() 377 | ..writeAsStringSync( 378 | const JsonEncoder.withIndent(' ').convert(OpenApi()), 379 | ); 380 | 381 | memoryFileSystem.file('/routes/index.dart').createSync(recursive: true); 382 | memoryFileSystem 383 | .file('/routes/todos/index.dart') 384 | .createSync(recursive: true); 385 | memoryFileSystem 386 | .file('/routes/todos/[title].dart') 387 | .createSync(recursive: true); 388 | 389 | const componentJson = ''' 390 | { 391 | "type": "object", 392 | "properties": { 393 | "title": { 394 | "type": "string" 395 | }, 396 | "completed": { 397 | "type": "boolean" 398 | } 399 | } 400 | } 401 | '''; 402 | memoryFileSystem.file('/components/todos.json') 403 | ..createSync(recursive: true) 404 | ..writeAsStringSync(componentJson); 405 | 406 | // WHEN 407 | final result = await commandRunner.run(['gen']); 408 | 409 | // THEN 410 | expect(result, equals(ExitCode.success.code)); 411 | final openApiFile = memoryFileSystem.file('/public/openapi.json'); 412 | expect(openApiFile.existsSync(), isTrue); 413 | final openApi = OpenApi.fromJson( 414 | jsonDecode( 415 | openApiFile.readAsStringSync(), 416 | ) as Map, 417 | ); 418 | expect(openApi.tags, isNotEmpty); 419 | expect(openApi.tags![0].name, equals('todos')); 420 | 421 | expect(openApi.paths, isNotEmpty); 422 | expect(openApi.paths['/todos']?.delete, isNotNull); 423 | expect(openApi.paths['/todos']?.get, isNotNull); 424 | expect(openApi.paths['/todos']?.head, isNotNull); 425 | expect(openApi.paths['/todos']?.options, isNotNull); 426 | expect(openApi.paths['/todos']?.patch, isNotNull); 427 | expect(openApi.paths['/todos']?.post, isNotNull); 428 | expect(openApi.paths['/todos']?.put, isNotNull); 429 | expect(openApi.paths['/todos']?.trace, isNull); 430 | 431 | expect(openApi.paths['/todos/{title}']?.delete, isNotNull); 432 | expect(openApi.paths['/todos/{title}']?.get, isNotNull); 433 | expect(openApi.paths['/todos/{title}']?.head, isNotNull); 434 | expect(openApi.paths['/todos/{title}']?.options, isNotNull); 435 | expect(openApi.paths['/todos/{title}']?.patch, isNotNull); 436 | expect(openApi.paths['/todos/{title}']?.post, isNotNull); 437 | expect(openApi.paths['/todos/{title}']?.put, isNotNull); 438 | expect(openApi.paths['/todos/{title}']?.trace, isNull); 439 | 440 | expect(openApi.components?.schemas, isNotEmpty); 441 | expect(openApi.components?.schemas['todos'], isNotNull); 442 | }); 443 | 444 | test( 445 | 'routes/[message].dart should generate OpenApi with no tag (issue #23)', 446 | () async { 447 | // GIVEN 448 | final publicDir = memoryFileSystem.directory('public'); 449 | if (!publicDir.existsSync()) { 450 | publicDir.createSync(); 451 | } 452 | memoryFileSystem.file('nenuphar.json') 453 | ..createSync() 454 | ..writeAsStringSync( 455 | const JsonEncoder.withIndent(' ').convert(OpenApi()), 456 | ); 457 | 458 | const messageFileContent = ''' 459 | import 'package:dart_frog/dart_frog.dart'; 460 | 461 | Response onRequest(RequestContext context, String message) { 462 | return Response(body: message); 463 | } 464 | '''; 465 | 466 | memoryFileSystem.file('/routes/[message].dart') 467 | ..createSync(recursive: true) 468 | ..writeAsStringSync(messageFileContent); 469 | 470 | // WHEN 471 | final result = await commandRunner.run(['gen']); 472 | 473 | // THEN 474 | expect(result, equals(ExitCode.success.code)); 475 | final openApiFile = memoryFileSystem.file('/public/openapi.json'); 476 | expect(openApiFile.existsSync(), isTrue); 477 | final openApi = OpenApi.fromJson( 478 | jsonDecode( 479 | openApiFile.readAsStringSync(), 480 | ) as Map, 481 | ); 482 | expect(openApi.tags, isNotEmpty); 483 | 484 | expect(openApi.paths['/{message}']?.delete, isNotNull); 485 | expect(openApi.paths['/{message}']?.get, isNotNull); 486 | expect(openApi.paths['/{message}']?.head, isNotNull); 487 | expect(openApi.paths['/{message}']?.options, isNotNull); 488 | expect(openApi.paths['/{message}']?.patch, isNotNull); 489 | expect(openApi.paths['/{message}']?.post, isNotNull); 490 | expect(openApi.paths['/{message}']?.put, isNotNull); 491 | expect(openApi.paths['/{message}']?.trace, isNull); 492 | 493 | expect(openApi.paths['/{message}']?.delete?.tags, ['']); 494 | expect(openApi.paths['/{message}']?.get?.tags, ['']); 495 | expect(openApi.paths['/{message}']?.head?.tags, ['']); 496 | expect(openApi.paths['/{message}']?.options?.tags, ['']); 497 | expect(openApi.paths['/{message}']?.patch?.tags, ['']); 498 | expect(openApi.paths['/{message}']?.post?.tags, ['']); 499 | expect(openApi.paths['/{message}']?.put?.tags, ['']); 500 | }); 501 | 502 | test('Should generate for auth routes (issue #21)', () async { 503 | // GIVEN 504 | final publicDir = memoryFileSystem.directory(Uri.directory('public')); 505 | if (!publicDir.existsSync()) { 506 | publicDir.createSync(); 507 | } 508 | memoryFileSystem.file(Uri.file('nenuphar.json')) 509 | ..createSync() 510 | ..writeAsStringSync( 511 | const JsonEncoder.withIndent(' ').convert(OpenApi()), 512 | ); 513 | 514 | const loginFileContent = ''' 515 | import 'dart:io'; 516 | import 'package:dart_frog/dart_frog.dart'; 517 | import 'package:travel_plan/controller/auth/auth_controller.dart'; 518 | 519 | /// 520 | /// The /api/auth/login routes 521 | /// 522 | /// @Allow(POST) - Allow only POST methods 523 | /// 524 | /// @Header(User-Name) - The user name header 525 | /// 526 | /// 527 | Future onRequest(RequestContext context) async { 528 | return switch(context.request.method) { 529 | HttpMethod.post => AuthController.instance.login(context), 530 | _ => Future.value(Response(statusCode: HttpStatus.methodNotAllowed)) 531 | }; 532 | } 533 | '''; 534 | 535 | memoryFileSystem.file(Uri.file('routes/api/auth/login.dart')) 536 | ..createSync(recursive: true) 537 | ..writeAsStringSync(loginFileContent); 538 | 539 | const registerFileContent = ''' 540 | import 'dart:io'; 541 | import 'package:dart_frog/dart_frog.dart'; 542 | import 'package:travel_plan/controller/auth/auth_controller.dart'; 543 | 544 | Future onRequest(RequestContext context) async { 545 | return switch(context.request.method) { 546 | HttpMethod.post => AuthController.instance.register(context), 547 | _ => Future.value(Response(statusCode: HttpStatus.methodNotAllowed)) 548 | }; 549 | } 550 | '''; 551 | 552 | memoryFileSystem.file(Uri.file('routes/api/auth/register.dart')) 553 | ..createSync(recursive: true) 554 | ..writeAsStringSync(registerFileContent); 555 | 556 | // WHEN 557 | final result = await commandRunner.run(['gen']); 558 | 559 | // THEN 560 | expect(result, equals(ExitCode.success.code)); 561 | final openApiFile = 562 | memoryFileSystem.file(Uri.file('/public/openapi.json')); 563 | expect(openApiFile.existsSync(), isTrue); 564 | }); 565 | 566 | test('Generates security schemes with reference and scopes', () async { 567 | // GIVEN 568 | final publicDir = memoryFileSystem.directory('public'); 569 | if (!publicDir.existsSync()) { 570 | publicDir.createSync(); 571 | } 572 | memoryFileSystem.file('nenuphar.json') 573 | ..createSync() 574 | ..writeAsStringSync( 575 | const JsonEncoder.withIndent(' ').convert(OpenApi()), 576 | ); 577 | 578 | memoryFileSystem.file('/routes/index.dart').createSync(recursive: true); 579 | 580 | const todosFileContent = ''' 581 | import 'dart:convert'; 582 | import 'dart:io'; 583 | 584 | import 'package:dart_frog/dart_frog.dart'; 585 | 586 | /// @Security(oauth_security) 587 | /// @Scope(read:tests) 588 | /// @Scope(write:tests) 589 | Future onRequest(RequestContext context) async { 590 | return Response(statusCode: HttpStatus.ok); 591 | } 592 | '''; 593 | 594 | memoryFileSystem.file('/routes/todos.dart') 595 | ..createSync(recursive: true) 596 | ..writeAsStringSync(todosFileContent); 597 | 598 | const securityFileContent = ''' 599 | { 600 | "basic_auth": { 601 | "type": "http", 602 | "scheme": "basic" 603 | }, 604 | "api_key": { 605 | "type": "apiKey", 606 | "name": "api_key", 607 | "in": "header" 608 | }, 609 | "oauth_security": { 610 | "type": "oauth2", 611 | "flows": { 612 | "implicit": { 613 | "authorizationUrl": "https://nenuphar.io/oauth/authorize", 614 | "scopes": { 615 | "write:tests": "modify tests", 616 | "read:tests": "read your tests" 617 | } 618 | } 619 | } 620 | } 621 | } 622 | 623 | '''; 624 | 625 | memoryFileSystem.file('/components/_security.json') 626 | ..createSync(recursive: true) 627 | ..writeAsStringSync(securityFileContent); 628 | 629 | // WHEN 630 | final result = await commandRunner.run(['gen']); 631 | 632 | // THEN 633 | expect(result, equals(ExitCode.success.code)); 634 | final openApiFile = memoryFileSystem.file('/public/openapi.json'); 635 | expect(openApiFile.existsSync(), isTrue); 636 | final openApi = OpenApi.fromJson( 637 | jsonDecode( 638 | openApiFile.readAsStringSync(), 639 | ) as Map, 640 | ); 641 | 642 | expect(openApi.paths, isNotEmpty); 643 | final getMethod = openApi.paths['/todos']?.get; 644 | expect(getMethod, isNotNull); 645 | expect(getMethod?.security, isNotEmpty); 646 | expect(getMethod?.security.first['oauth_security'], isNotNull); 647 | expect( 648 | getMethod?.security.first['oauth_security']?.contains('read:tests'), 649 | isTrue, 650 | ); 651 | expect( 652 | getMethod?.security.first['oauth_security']?.contains('write:tests'), 653 | isTrue, 654 | ); 655 | expect(openApi.components?.securitySchemes, isNotEmpty); 656 | expect(openApi.components?.securitySchemes['basic_auth']?.type, 'http'); 657 | expect( 658 | openApi.components?.securitySchemes['basic_auth']?.scheme, 659 | 'basic', 660 | ); 661 | expect(openApi.components?.securitySchemes['api_key']?.type, 'apiKey'); 662 | expect( 663 | openApi.components?.securitySchemes['api_key']?.name, 664 | 'api_key', 665 | ); 666 | expect( 667 | openApi.components?.securitySchemes['api_key']?.inLocation, 668 | 'header', 669 | ); 670 | expect( 671 | openApi.components?.securitySchemes['oauth_security']?.type, 672 | 'oauth2', 673 | ); 674 | expect( 675 | openApi.components?.securitySchemes['oauth_security']?.flows?.implicit 676 | ?.authorizationUrl, 677 | 'https://nenuphar.io/oauth/authorize', 678 | ); 679 | expect( 680 | openApi.components?.securitySchemes['oauth_security']?.flows?.implicit 681 | ?.scopes 682 | .containsKey('read:tests'), 683 | isTrue, 684 | ); 685 | expect( 686 | openApi.components?.securitySchemes['oauth_security']?.flows?.implicit 687 | ?.scopes 688 | .containsKey('write:tests'), 689 | isTrue, 690 | ); 691 | }); 692 | }); 693 | } 694 | -------------------------------------------------------------------------------- /lib/src/commands/gen_command.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'package:args/command_runner.dart'; 3 | import 'package:file/file.dart'; 4 | import 'package:mason_logger/mason_logger.dart'; 5 | import 'package:nenuphar_cli/src/extensions/string_extension.dart'; 6 | import 'package:nenuphar_cli/src/models/models.dart'; 7 | import 'package:nenuphar_cli/src/tooling.dart'; 8 | 9 | /// {@template sample_command} 10 | /// 11 | /// `nenuphar gen` 12 | /// A [Command] to generate OpenAPI file 13 | /// {@endtemplate} 14 | class GenCommand extends Command { 15 | /// {@macro sample_command} 16 | GenCommand({ 17 | required Logger logger, 18 | required FileSystem fileSystem, 19 | }) : _logger = logger, 20 | _fileSystem = fileSystem { 21 | argParser.addOption( 22 | 'output', 23 | abbr: 'o', 24 | help: 'Specify the output file', 25 | ); 26 | } 27 | 28 | @override 29 | String get description => 'Sub command that generated OpenAPI file'; 30 | 31 | @override 32 | String get name => 'gen'; 33 | 34 | final Logger _logger; 35 | 36 | final FileSystem _fileSystem; 37 | 38 | /// A regex to find multiple {parameter} in path 39 | /// like /todos/{id}/comments/{commentId} 40 | final pathParamGroups = RegExp(r'\{([a-zA-Z]+)\}+'); 41 | 42 | @override 43 | Future run() async { 44 | _logger 45 | ..info('Will generate OpenAPI file') 46 | ..info('input is: ${_fileSystem.currentDirectory.path}'); 47 | if (argResults?['output'] != null) { 48 | _logger.info( 49 | 'output is: ${lightCyan.wrap(argResults!['output'].toString())!}', 50 | ); 51 | } 52 | 53 | // List every *.dart files in routes folder 54 | final routes = _locateRoutes( 55 | _fileSystem.currentDirectory.childDirectory('routes').path, 56 | ); 57 | 58 | _logger.info('Found ${routes.join('\n')} routes'); 59 | 60 | try { 61 | final openApi = _generateOpenApi(routes); 62 | 63 | final json = jsonPrettyEncoder.convert(openApi.toJson()); 64 | 65 | // save to file 66 | final output = argResults?['output'] ?? 'public/openapi.json'; 67 | final file = _fileSystem.file(output.toString()); 68 | await file.writeAsString(json); 69 | 70 | return ExitCode.success.code; 71 | } catch (e) { 72 | _logger.alert('Failed to generate OpenAPI file: $e'); 73 | return ExitCode.usage.code; 74 | } 75 | } 76 | 77 | OpenApi _generateOpenApi(List routes) { 78 | final paths = {}; 79 | final tags = []; 80 | final schemas = {}; 81 | 82 | for (final route in routes) { 83 | _parseRoute(route, tags, paths, schemas); 84 | } 85 | 86 | // Read nenuphar.json file 87 | final nenupharJsonFile = _fileSystem.file('nenuphar.json'); 88 | if (!nenupharJsonFile.existsSync()) { 89 | _logger.alert( 90 | 'nenuphar.json file not found. Please run nenuphar init first', 91 | ); 92 | throw Exception('nenuphar.json file not found'); 93 | } 94 | 95 | final openAPiBase = OpenApi.fromJson( 96 | jsonDecode( 97 | nenupharJsonFile.readAsStringSync(), 98 | ) as Map, 99 | ); 100 | 101 | final securityJsonFile = 102 | _fileSystem.directory('components').childFile('_security.json'); 103 | var securitySchemes = {}; 104 | if (securityJsonFile.existsSync()) { 105 | final securityJson = jsonDecode( 106 | securityJsonFile.readAsStringSync(), 107 | ) as Map; 108 | securitySchemes = securityJson.map( 109 | (name, scheme) => MapEntry( 110 | name, 111 | SecurityScheme.fromJson( 112 | scheme as Map, 113 | ), 114 | ), 115 | ); 116 | } 117 | 118 | return openAPiBase 119 | ..tags = tags 120 | ..paths = paths 121 | ..components = Components( 122 | schemas: schemas, 123 | securitySchemes: securitySchemes, 124 | ); 125 | } 126 | 127 | void _parseRoute( 128 | String route, 129 | List tags, 130 | Map paths, 131 | Map schemas, 132 | ) { 133 | _logger.info(' parsing route: $route'); 134 | 135 | final path = route 136 | .replaceFirst( 137 | _fileSystem.currentDirectory.childDirectory('routes').path, 138 | '', 139 | ) 140 | .replaceAll(r'\', '/') 141 | .replaceFirst('/index.dart', '') 142 | .replaceFirst('.dart', '') 143 | .replaceAll('[', '{') 144 | .replaceAll(']', '}'); 145 | 146 | if (path.isEmpty) { 147 | return; 148 | } 149 | 150 | _logger.info(' evaluating path: $path'); 151 | 152 | final tag = path 153 | .split('/') 154 | .where((segment) => segment.isNotEmpty) 155 | .where( 156 | (segment) => !segment.contains( 157 | RegExp(r'\{.*\}*'), 158 | ), 159 | ) 160 | .lastOrNull ?? 161 | ''; 162 | 163 | if (!schemas.containsKey(tag)) { 164 | _generateComponent(schemas, tag); 165 | } 166 | 167 | if (!tags.any((e) => e.name == tag)) { 168 | tags.add( 169 | Tag( 170 | name: tag, 171 | description: 'Operations about $tag', 172 | ), 173 | ); 174 | } 175 | 176 | final pathParams = _extractPathParams(path); 177 | final headerParams = _extractHeaderParams(route); 178 | final queryParams = _extractQueryParams(route); 179 | final allowedMethods = _extractAllowMethods(route); 180 | final securityNames = _extractSecurityNames(route); 181 | final securityScopes = _extractSecurityScopes(route); 182 | 183 | if (allowedMethods.isEmpty) { 184 | _logger.info('No allowed methods found for $path'); 185 | 186 | allowedMethods.addAll([ 187 | 'options', 188 | 'get', 189 | 'head', 190 | 'post', 191 | 'put', 192 | 'patch', 193 | 'delete', 194 | ]); 195 | 196 | _logger.info('Using default allowed methods: $allowedMethods'); 197 | } 198 | 199 | paths[path] = Paths( 200 | options: _generateOptionMethod( 201 | path: path, 202 | pathParams: pathParams, 203 | headerParams: headerParams, 204 | tag: tag, 205 | methodAllowed: allowedMethods.contains('options'), 206 | securityNames: securityNames, 207 | securityScopes: securityScopes, 208 | ), 209 | get: _generateGetMethod( 210 | path: path, 211 | pathParams: pathParams, 212 | headerParams: headerParams, 213 | queryParams: queryParams, 214 | tag: tag, 215 | existingSchema: schemas.containsKey(tag), 216 | methodAllowed: allowedMethods.contains('get'), 217 | securityNames: securityNames, 218 | securityScopes: securityScopes, 219 | ), 220 | head: _generateHeadMethod( 221 | path: path, 222 | pathParams: pathParams, 223 | headerParams: headerParams, 224 | tag: tag, 225 | methodAllowed: allowedMethods.contains('head'), 226 | securityNames: securityNames, 227 | securityScopes: securityScopes, 228 | ), 229 | post: _generatePostMethod( 230 | path: path, 231 | pathParams: pathParams, 232 | headerParams: headerParams, 233 | queryParams: queryParams, 234 | tag: tag, 235 | existingSchema: schemas.containsKey(tag), 236 | methodAllowed: allowedMethods.contains('post'), 237 | securityNames: securityNames, 238 | securityScopes: securityScopes, 239 | ), 240 | put: _generatePutMethod( 241 | path: path, 242 | pathParams: pathParams, 243 | headerParams: headerParams, 244 | queryParams: queryParams, 245 | tag: tag, 246 | existingSchema: schemas.containsKey(tag), 247 | methodAllowed: allowedMethods.contains('put'), 248 | securityNames: securityNames, 249 | securityScopes: securityScopes, 250 | ), 251 | patch: _generatePutMethod( 252 | path: path, 253 | pathParams: pathParams, 254 | headerParams: headerParams, 255 | queryParams: queryParams, 256 | tag: tag, 257 | existingSchema: schemas.containsKey(tag), 258 | methodAllowed: allowedMethods.contains('patch'), 259 | securityNames: securityNames, 260 | securityScopes: securityScopes, 261 | ), 262 | delete: _generateDeleteMethod( 263 | path: path, 264 | pathParams: pathParams, 265 | headerParams: headerParams, 266 | queryParams: queryParams, 267 | tag: tag, 268 | methodAllowed: allowedMethods.contains('delete'), 269 | securityNames: securityNames, 270 | securityScopes: securityScopes, 271 | ), 272 | ); 273 | } 274 | 275 | void _generateComponent(Map schemas, String tag) { 276 | // Read file 277 | final file = _fileSystem.file( 278 | '${_fileSystem.currentDirectory.path}/components/$tag.json', 279 | ); 280 | if (file.existsSync()) { 281 | final json = jsonDecode(file.readAsStringSync()) as Map; 282 | final schema = Schema.fromJson(json); 283 | schemas[tag] = schema; 284 | } 285 | } 286 | 287 | /// 288 | /// Generate a DELETE method for [path] 289 | /// 290 | Method? _generateDeleteMethod({ 291 | required String path, 292 | required List pathParams, 293 | required List headerParams, 294 | required List queryParams, 295 | required String tag, 296 | required bool methodAllowed, 297 | required List securityNames, 298 | required List securityScopes, 299 | }) { 300 | if (methodAllowed) { 301 | return Method( 302 | tags: [tag], 303 | parameters: pathParams 304 | .map( 305 | (e) => Parameter( 306 | name: e, 307 | inLocation: InLocation.path, 308 | required: true, 309 | schema: const Schema( 310 | type: 'string', 311 | ), 312 | ), 313 | ) 314 | .toList() 315 | ..addAll( 316 | headerParams.map( 317 | (e) => Parameter( 318 | name: e, 319 | inLocation: InLocation.header, 320 | schema: const Schema( 321 | type: 'string', 322 | ), 323 | ), 324 | ), 325 | ) 326 | ..addAll( 327 | queryParams.map( 328 | (e) => Parameter( 329 | name: e, 330 | schema: const Schema( 331 | type: 'string', 332 | ), 333 | ), 334 | ), 335 | ), 336 | responses: { 337 | 204: const ResponseBody( 338 | description: 'Deleted', 339 | ), 340 | }, 341 | security: securityNames 342 | .map( 343 | (name) => {name: securityScopes}, 344 | ) 345 | .toList(), 346 | ); 347 | } 348 | return null; 349 | } 350 | 351 | /// 352 | /// Generate a POST method for [path] 353 | /// 354 | Method? _generatePostMethod({ 355 | required String path, 356 | required List pathParams, 357 | required List headerParams, 358 | required List queryParams, 359 | required String tag, 360 | required bool existingSchema, 361 | required bool methodAllowed, 362 | required List securityNames, 363 | required List securityScopes, 364 | }) { 365 | if (methodAllowed) { 366 | final schemaReference = existingSchema 367 | ? Schema(ref: '#/components/schemas/$tag') 368 | : Schema.emptyObject(); 369 | 370 | return Method( 371 | tags: [tag], 372 | parameters: pathParams 373 | .map( 374 | (e) => Parameter( 375 | name: e, 376 | inLocation: InLocation.path, 377 | required: true, 378 | schema: const Schema( 379 | type: 'string', 380 | ), 381 | ), 382 | ) 383 | .toList() 384 | ..addAll( 385 | headerParams.map( 386 | (e) => Parameter( 387 | name: e, 388 | inLocation: InLocation.header, 389 | schema: const Schema( 390 | type: 'string', 391 | ), 392 | ), 393 | ), 394 | ) 395 | ..addAll( 396 | queryParams.map( 397 | (e) => Parameter( 398 | name: e, 399 | schema: const Schema( 400 | type: 'string', 401 | ), 402 | ), 403 | ), 404 | ), 405 | requestBody: RequestBody( 406 | content: { 407 | 'application/json': MediaType( 408 | schema: schemaReference, 409 | ), 410 | }, 411 | ), 412 | responses: { 413 | 201: ResponseBody( 414 | description: 'Created $tag.', 415 | ), 416 | }, 417 | security: securityNames 418 | .map( 419 | (name) => {name: securityScopes}, 420 | ) 421 | .toList(), 422 | ); 423 | } 424 | return null; 425 | } 426 | 427 | /// 428 | /// Generate a PUT method for [path] 429 | /// 430 | Method? _generatePutMethod({ 431 | required String path, 432 | required List pathParams, 433 | required List headerParams, 434 | required List queryParams, 435 | required String tag, 436 | required bool existingSchema, 437 | required bool methodAllowed, 438 | required List securityNames, 439 | required List securityScopes, 440 | }) { 441 | if (methodAllowed) { 442 | final schemaReference = existingSchema 443 | ? Schema(ref: '#/components/schemas/$tag') 444 | : Schema.emptyObject(); 445 | 446 | return Method( 447 | tags: [tag], 448 | parameters: pathParams 449 | .map( 450 | (e) => Parameter( 451 | name: e, 452 | inLocation: InLocation.path, 453 | required: true, 454 | schema: const Schema( 455 | type: 'string', 456 | ), 457 | ), 458 | ) 459 | .toList() 460 | ..addAll( 461 | headerParams.map( 462 | (e) => Parameter( 463 | name: e, 464 | inLocation: InLocation.header, 465 | schema: const Schema( 466 | type: 'string', 467 | ), 468 | ), 469 | ), 470 | ) 471 | ..addAll( 472 | queryParams.map( 473 | (e) => Parameter( 474 | name: e, 475 | schema: const Schema( 476 | type: 'string', 477 | ), 478 | ), 479 | ), 480 | ), 481 | requestBody: RequestBody( 482 | content: { 483 | 'application/json': MediaType( 484 | schema: schemaReference, 485 | ), 486 | }, 487 | ), 488 | responses: { 489 | 200: ResponseBody( 490 | description: 'A list of $tag.', 491 | content: { 492 | 'application/json': MediaType( 493 | schema: schemaReference, 494 | ), 495 | }, 496 | ), 497 | }, 498 | security: securityNames 499 | .map( 500 | (name) => {name: securityScopes}, 501 | ) 502 | .toList(), 503 | ); 504 | } 505 | return null; 506 | } 507 | 508 | /// 509 | /// Generate a GET method for [path] 510 | /// 511 | Method? _generateGetMethod({ 512 | required String path, 513 | required List pathParams, 514 | required List headerParams, 515 | required List queryParams, 516 | required String tag, 517 | required bool existingSchema, 518 | required bool methodAllowed, 519 | required List securityNames, 520 | required List securityScopes, 521 | }) { 522 | final isList = !path.endsWithPathParam(); 523 | 524 | final schemaReference = existingSchema 525 | ? Schema(ref: '#/components/schemas/$tag') 526 | : Schema.emptyObject(); 527 | 528 | if (!methodAllowed) { 529 | return null; 530 | } 531 | 532 | return Method( 533 | tags: [tag], 534 | parameters: pathParams 535 | .map( 536 | (e) => Parameter( 537 | name: e, 538 | inLocation: InLocation.path, 539 | required: true, 540 | schema: const Schema( 541 | type: 'string', 542 | ), 543 | ), 544 | ) 545 | .toList() 546 | ..addAll( 547 | headerParams.map( 548 | (e) => Parameter( 549 | name: e, 550 | inLocation: InLocation.header, 551 | schema: const Schema( 552 | type: 'string', 553 | ), 554 | ), 555 | ), 556 | ) 557 | ..addAll( 558 | queryParams.map( 559 | (e) => Parameter( 560 | name: e, 561 | schema: const Schema( 562 | type: 'string', 563 | ), 564 | ), 565 | ), 566 | ), 567 | responses: { 568 | 200: ResponseBody( 569 | description: isList ? 'A list of $tag.' : 'A $tag.', 570 | content: { 571 | 'application/json': MediaType( 572 | schema: isList 573 | ? Schema( 574 | type: 'array', 575 | items: schemaReference, 576 | ) 577 | : schemaReference, 578 | ), 579 | }, 580 | ), 581 | }, 582 | security: securityNames 583 | .map( 584 | (name) => {name: securityScopes}, 585 | ) 586 | .toList(), 587 | ); 588 | } 589 | 590 | /// 591 | /// Generate a HEAD method for [path] 592 | /// 593 | Method? _generateHeadMethod({ 594 | required String path, 595 | required List pathParams, 596 | required List headerParams, 597 | required String tag, 598 | required bool methodAllowed, 599 | required List securityNames, 600 | required List securityScopes, 601 | }) { 602 | if (!methodAllowed) { 603 | return null; 604 | } 605 | 606 | return Method( 607 | tags: [tag], 608 | parameters: pathParams 609 | .map( 610 | (e) => Parameter( 611 | name: e, 612 | inLocation: InLocation.path, 613 | required: true, 614 | schema: const Schema( 615 | type: 'string', 616 | ), 617 | ), 618 | ) 619 | .toList() 620 | ..addAll( 621 | headerParams.map( 622 | (e) => Parameter( 623 | name: e, 624 | inLocation: InLocation.header, 625 | schema: const Schema( 626 | type: 'string', 627 | ), 628 | ), 629 | ), 630 | ), 631 | responses: { 632 | 200: ResponseBody( 633 | description: 'Meta informations about $tag.', 634 | ), 635 | }, 636 | security: securityNames 637 | .map( 638 | (name) => {name: securityScopes}, 639 | ) 640 | .toList(), 641 | ); 642 | } 643 | 644 | /// 645 | /// Generate a OPTION method for [path] 646 | /// 647 | Method? _generateOptionMethod({ 648 | required String path, 649 | required List pathParams, 650 | required List headerParams, 651 | required String tag, 652 | required bool methodAllowed, 653 | required List securityNames, 654 | required List securityScopes, 655 | }) { 656 | if (!methodAllowed) { 657 | return null; 658 | } 659 | return Method( 660 | tags: [tag], 661 | parameters: pathParams 662 | .map( 663 | (e) => Parameter( 664 | name: e, 665 | inLocation: InLocation.path, 666 | required: true, 667 | schema: const Schema( 668 | type: 'string', 669 | ), 670 | ), 671 | ) 672 | .toList() 673 | ..addAll( 674 | headerParams.map( 675 | (e) => Parameter( 676 | name: e, 677 | inLocation: InLocation.header, 678 | schema: const Schema( 679 | type: 'string', 680 | ), 681 | ), 682 | ), 683 | ), 684 | responses: { 685 | 204: ResponseBody( 686 | description: 'Allowed HTTP methods for $path', 687 | headers: { 688 | 'Allow': Header( 689 | description: 'Allowed HTTP methods for $path', 690 | schema: const Schema( 691 | type: 'string', 692 | ), 693 | ), 694 | }, 695 | ), 696 | }, 697 | security: securityNames 698 | .map( 699 | (name) => {name: securityScopes}, 700 | ) 701 | .toList(), 702 | ); 703 | } 704 | 705 | /// 706 | /// Extract security scopes from [routeFile] 707 | /// like /// @Scope(scope_name) 708 | /// will return ['scope_name'] 709 | /// if no security scope is found, return an empty list 710 | /// 711 | List _extractSecurityScopes(String routeFile) { 712 | final file = _fileSystem.file(routeFile); 713 | final content = file.readAsStringSync(); 714 | final matches = RegExp(r'///\s*@Scope\((.*)\)').allMatches(content); 715 | return matches 716 | .map((e) => e.group(1)!) 717 | .map((it) => it.split(',')) 718 | .expand((it) => it) // flatten List> => List 719 | .toList(); 720 | } 721 | 722 | /// 723 | /// Extract security names from [routeFile] 724 | /// like /// @Security(api_key) 725 | /// will return ['api_key'] 726 | /// if no security name is found, return an empty list 727 | /// 728 | List _extractSecurityNames(String routeFile) { 729 | final file = _fileSystem.file(routeFile); 730 | final content = file.readAsStringSync(); 731 | final matches = RegExp(r'///\s*@Security\((.*)\)').allMatches(content); 732 | return matches 733 | .map((e) => e.group(1)!) 734 | .map((it) => it.split(',')) 735 | .expand((it) => it) // flatten List> => List 736 | .toList(); 737 | } 738 | 739 | /// 740 | /// Extract allowed methods from [routeFile] 741 | /// like /// @Allow(OPTIONS, GET, HEAD, POST, PUT, PATCH, DELETE) 742 | /// will return ['options', 'get', 'head', 'post', 'put', 'patch', 'delete'] 743 | /// if no allowed methods is found, return an empty list 744 | /// 745 | List _extractAllowMethods(String routeFile) { 746 | final file = _fileSystem.file(routeFile); 747 | final content = file.readAsStringSync(); 748 | final matches = RegExp(r'///\s*@Allow\((.*)\)').allMatches(content); 749 | return matches 750 | .map( 751 | (e) => e.group(1)!, 752 | ) // Match found group (@Allow(OPTIONS, GET) => OPTIONS, GET) 753 | .map((it) => it.split(',')) 754 | .expand((it) => it) // flatten List> => List 755 | .map((it) => it.trim().toLowerCase()) // lowercase (GET => get) 756 | .toList(); 757 | } 758 | 759 | /// 760 | /// Extract query parameters from [routeFile] 761 | /// like /// @Query(completed) 762 | /// will return ['completed'] 763 | /// if no query is found, return an empty list 764 | /// 765 | List _extractQueryParams(String routeFile) { 766 | final file = _fileSystem.file(routeFile); 767 | final content = file.readAsStringSync(); 768 | final matches = RegExp(r'///\s*@Query\((.*)\)').allMatches(content); 769 | return matches.map((e) => e.group(1)!).toList(); 770 | } 771 | 772 | /// 773 | /// Extract header parameters from [routeFile] 774 | /// like /// @Header(Authorization) 775 | /// will return ['Authorization'] 776 | /// if no header is found, return an empty list 777 | /// 778 | List _extractHeaderParams(String routeFile) { 779 | final file = _fileSystem.file(routeFile); 780 | final content = file.readAsStringSync(); 781 | final matches = RegExp(r'///\s*@Header\((.*)\)').allMatches(content); 782 | return matches.map((e) => e.group(1)!).toList(); 783 | } 784 | 785 | /// 786 | /// Extract path parameters from [path] 787 | /// like /todos/{id}/comments/{commentId} 788 | /// will return ['id', 'commentId'] 789 | /// 790 | List _extractPathParams(String path) { 791 | final matches = pathParamGroups.allMatches(path); 792 | final pathParams = matches.map((e) => e.group(1)!).toList(); 793 | return pathParams; 794 | } 795 | 796 | /// 797 | /// Recursively list every *.dart files in [path] folder 798 | /// and return a list of their paths 799 | /// 800 | /// middleware files are ignored 801 | /// 802 | List _locateRoutes(String path) { 803 | final entities = _fileSystem.directory(path).listSync(); 804 | 805 | final routes = entities 806 | .where((e) => e.path.endsWith('.dart')) 807 | .where((e) => !e.path.endsWith('_middleware.dart')) 808 | .map((e) => e.path) 809 | .toList(); 810 | 811 | final directories = entities.whereType(); 812 | for (final dir in directories) { 813 | routes.addAll(_locateRoutes(dir.path)); 814 | } 815 | 816 | return routes; 817 | } 818 | } 819 | --------------------------------------------------------------------------------