From 624386a23473dbdb70b9b0a9e2bb909a6c5d42e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20Bouchard?= Date: Wed, 15 Jan 2025 17:08:48 -0500 Subject: [PATCH 1/2] WIP --- example/example_open_api.dart | 42 +++++++++ lib/src/alfred_openapi.dart | 158 ++++++++++++++++++++++++++++++++++ lib/src/http_route.dart | 16 +++- lib/src/router.dart | 34 +++++--- pubspec.yaml | 2 + 5 files changed, 239 insertions(+), 13 deletions(-) create mode 100644 example/example_open_api.dart create mode 100644 lib/src/alfred_openapi.dart diff --git a/example/example_open_api.dart b/example/example_open_api.dart new file mode 100644 index 0000000..e7dd835 --- /dev/null +++ b/example/example_open_api.dart @@ -0,0 +1,42 @@ +import 'package:alfred/alfred.dart'; +import 'package:alfred/src/alfred_openapi.dart'; + +void main() async { + final app = Alfred(); + + app.get( + '/path/:param1', + (req, res) { + res.json({'key': 'value'}); + }, + middleware: [], + openAPIDoc: OpenAPIDoc( + title: 'Title of the endpoint', + description: 'Description for the endpoint', + responses: [ + OpenAPIResponse( + statusCode: 200, + description: 'Success', + schema: [ + OpenAPIResponseContent( + key: 'key', + type: OpenAPIType.string, + example: 'value ABC', + ), + ], + ), + ], + ), + ); + + app.get('/openapi', (req, res) { + res.setContentTypeFromExtension('yaml'); + return app.getRoutesSpecifications( + title: "Test API", + description: "Test API Description", + version: "1.2.3", + ); + }); + + await app.listen(); +} diff --git a/lib/src/alfred_openapi.dart b/lib/src/alfred_openapi.dart new file mode 100644 index 0000000..41fdd47 --- /dev/null +++ b/lib/src/alfred_openapi.dart @@ -0,0 +1,158 @@ +import 'package:alfred/alfred.dart'; +import 'package:yaml_writer/yaml_writer.dart'; + +enum OpenAPIType { + string, + number, + integer, + boolean, + array, + object; + + String toJson() { + return toString().split('.').last; + } +} + +enum OpenAPIContentType { + object, + array; + + String toJson() { + return toString().split('.').last; + } +} + +/// Extension to generate OpenAPI specifications for the routes in the Alfred app +extension AlfredOpenAPI on Alfred { + /// Returns the OpenAPI specifications for the routes in the Alfred app + /// - [title] is the title of the API + /// - [description] is the description of the API + /// - [version] is the version of the API + String getRoutesSpecifications( + {String? title, String? description, String? version}) { + var specsYaml = YamlWriter(); + Map specs = { + 'openapi': '3.0.0', + 'info': { + 'title': title ?? 'API', + 'description': description ?? 'API Description', + 'version': version ?? '1.0.0' + }, + 'paths': [], + }; + for (var route in routes) { + String methodString = route.method.name; + var routeParameters = >[]; + + for (var parameter in route.params) { + routeParameters.add({ + 'name': parameter.name, + 'in': 'query', + 'required': true, + 'schema': {'type': parameter.type?.name ?? 'string'} + }); + } + + (specs['paths'] as List).add({ + route.route: { + methodString: { + 'summary': route.openAPIDoc?.title ?? 'Summary', + if (route.openAPIDoc?.description != null) + 'description': route.openAPIDoc!.description, + 'parameters': routeParameters, + 'responses': { + if (route.openAPIDoc?.responses != null) + for (var response in route.openAPIDoc!.responses) + ...response.toJson(), + if (route.openAPIDoc?.responses == null || + route.openAPIDoc!.responses.isEmpty) + '200': { + 'description': 'Success', + } + } + } + } + }); + } + + return specsYaml.write(specs).replaceAll('- /', '/'); + } +} + +/// Class to define the OpenAPI documentation +class OpenAPIDoc { + final String title; + final String? description; + final List responses; + + OpenAPIDoc({ + required this.title, + this.description, + required this.responses, + }); +} + +/// Class to define the OpenAPI response +class OpenAPIResponse { + final int statusCode; + final String? description; + final String contentType; + final OpenAPIContentType content; + final List schema; + + OpenAPIResponse({ + required this.statusCode, + this.description, + this.contentType = 'application/json', + this.content = OpenAPIContentType.object, + required this.schema, + }); + + Map toJson() { + return { + '$statusCode': { + 'description': description, + 'content': { + contentType: { + 'schema': { + 'type': content.toJson(), + if (content == OpenAPIContentType.object) + 'properties': { + for (var item in schema) ...item.toJson(), + }, + if (content == OpenAPIContentType.array) + 'items': { + 'type': schema.first.type.toJson(), + if (schema.first.example != null) + 'example': schema.first.example, + }, + }, + }, + }, + } + }; + } +} + +/// Class to define the OpenAPI response content +class OpenAPIResponseContent { + final String key; + final OpenAPIType type; + final dynamic example; + + OpenAPIResponseContent({ + required this.key, + required this.type, + this.example, + }); + + Map toJson() { + return { + key: { + 'type': type.toJson(), + if (example != null) 'example': example, + } + }; + } +} diff --git a/lib/src/http_route.dart b/lib/src/http_route.dart index 3207b14..ca412a4 100644 --- a/lib/src/http_route.dart +++ b/lib/src/http_route.dart @@ -1,10 +1,16 @@ import 'dart:async'; +import 'package:alfred/src/alfred_openapi.dart'; + import '../alfred.dart'; class HttpRoute { final Method method; final String route; + + /// The OpenAPI documentation for this route + final OpenAPIDoc? openAPIDoc; + final FutureOr Function(HttpRequest req, HttpResponse res) callback; final List middleware; @@ -20,9 +26,13 @@ class HttpRoute { Iterable get params => _params.values; - HttpRoute(this.route, this.callback, this.method, - {this.middleware = const []}) - : usesWildcardMatcher = route.contains('*') { + HttpRoute( + this.route, + this.callback, + this.method, { + this.middleware = const [], + this.openAPIDoc, + }) : usesWildcardMatcher = route.contains('*') { // Split route path into segments /// Because in dart 2.18 uri parsing is more permissive, using a \ in regex diff --git a/lib/src/router.dart b/lib/src/router.dart index 7efa56c..e3807af 100644 --- a/lib/src/router.dart +++ b/lib/src/router.dart @@ -5,6 +5,7 @@ import 'package:alfred/src/route_group.dart'; import 'package:meta/meta.dart'; import 'alfred.dart'; +import 'alfred_openapi.dart'; import 'http_route.dart'; mixin Router { @@ -20,8 +21,9 @@ mixin Router { FutureOr Function(HttpRequest req, HttpResponse res) callback, { List middleware = const [], + OpenAPIDoc? openAPIDoc, }) => - createRoute(Method.get, path, callback, middleware); + createRoute(Method.get, path, callback, middleware, openAPIDoc); /// Create a head route /// @@ -30,8 +32,9 @@ mixin Router { FutureOr Function(HttpRequest req, HttpResponse res) callback, { List middleware = const [], + OpenAPIDoc? openAPIDoc, }) => - createRoute(Method.head, path, callback, middleware); + createRoute(Method.head, path, callback, middleware, openAPIDoc); /// Create a post route /// @@ -40,8 +43,9 @@ mixin Router { FutureOr Function(HttpRequest req, HttpResponse res) callback, { List middleware = const [], + OpenAPIDoc? openAPIDoc, }) => - createRoute(Method.post, path, callback, middleware); + createRoute(Method.post, path, callback, middleware, openAPIDoc); /// Create a put route HttpRoute put( @@ -49,8 +53,9 @@ mixin Router { FutureOr Function(HttpRequest req, HttpResponse res) callback, { List middleware = const [], + OpenAPIDoc? openAPIDoc, }) => - createRoute(Method.put, path, callback, middleware); + createRoute(Method.put, path, callback, middleware, openAPIDoc); /// Create a delete route /// @@ -59,8 +64,9 @@ mixin Router { FutureOr Function(HttpRequest req, HttpResponse res) callback, { List middleware = const [], + OpenAPIDoc? openAPIDoc, }) => - createRoute(Method.delete, path, callback, middleware); + createRoute(Method.delete, path, callback, middleware, openAPIDoc); /// Create a patch route /// @@ -69,8 +75,9 @@ mixin Router { FutureOr Function(HttpRequest req, HttpResponse res) callback, { List middleware = const [], + OpenAPIDoc? openAPIDoc, }) => - createRoute(Method.patch, path, callback, middleware); + createRoute(Method.patch, path, callback, middleware, openAPIDoc); /// Create an options route /// @@ -79,8 +86,9 @@ mixin Router { FutureOr Function(HttpRequest req, HttpResponse res) callback, { List middleware = const [], + OpenAPIDoc? openAPIDoc, }) => - createRoute(Method.options, path, callback, middleware); + createRoute(Method.options, path, callback, middleware, openAPIDoc); /// Create a route that listens on all methods /// @@ -89,8 +97,9 @@ mixin Router { FutureOr Function(HttpRequest req, HttpResponse res) callback, { List middleware = const [], + OpenAPIDoc? openAPIDoc, }) => - createRoute(Method.all, path, callback, middleware); + createRoute(Method.all, path, callback, middleware, openAPIDoc); HttpRoute createRoute( Method method, @@ -98,10 +107,15 @@ mixin Router { FutureOr Function(HttpRequest req, HttpResponse res) callback, [ List middleware = const [], + OpenAPIDoc? openAPIDoc, ]) { final route = HttpRoute( - '${pathPrefix == '' ? '' : '$pathPrefix/'}$path', callback, method, - middleware: middleware); + '${pathPrefix == '' ? '' : '$pathPrefix/'}$path', + callback, + method, + middleware: middleware, + openAPIDoc: openAPIDoc, + ); app.addRoute(route); return route; } diff --git a/pubspec.yaml b/pubspec.yaml index 6254275..10faf95 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -12,6 +12,8 @@ dependencies: queue: ^3.1.0 path: ^1.8.2 meta: ^1.3.0 + yaml: ^3.1.3 + yaml_writer: ^2.0.1 dev_dependencies: test: ^1.21.4 From 59420eafce9563317ed5e8128b620719b56d0fad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20Bouchard?= Date: Thu, 16 Jan 2025 08:44:17 -0500 Subject: [PATCH 2/2] Supporting request body parameters --- example/example_open_api.dart | 23 ++++++++++++++++- lib/alfred.dart | 1 + lib/src/alfred_openapi.dart | 48 ++++++++++++++++++++++++++++++++++- lib/src/http_route.dart | 4 +-- 4 files changed, 71 insertions(+), 5 deletions(-) diff --git a/example/example_open_api.dart b/example/example_open_api.dart index e7dd835..9fab3ee 100644 --- a/example/example_open_api.dart +++ b/example/example_open_api.dart @@ -1,5 +1,4 @@ import 'package:alfred/alfred.dart'; -import 'package:alfred/src/alfred_openapi.dart'; void main() async { final app = Alfred(); @@ -29,6 +28,28 @@ void main() async { ), ); + app.patch( + '/path/:param2', + (req, res) { + res.json({'key': 'value'}); + }, + middleware: [], + openAPIDoc: OpenAPIDoc( + title: 'Title of the endpoint', + description: 'Description for the endpoint', + responses: [], + request: OpenAPIRequest( + schema: [ + OpenAPIResponseContent( + key: 'key', + type: OpenAPIType.string, + example: 'value ABC', + ), + ], + ), + ), + ); + app.get('/openapi', (req, res) { res.setContentTypeFromExtension('yaml'); return app.getRoutesSpecifications( diff --git a/lib/alfred.dart b/lib/alfred.dart index c0d7e61..8e85faf 100644 --- a/lib/alfred.dart +++ b/lib/alfred.dart @@ -2,6 +2,7 @@ export 'dart:io' show HttpRequest, HttpResponse; export 'src/alfred.dart'; export 'src/alfred_exception.dart'; +export 'src/alfred_openapi.dart'; export 'src/router.dart'; export 'src/body_parser/http_body.dart'; export 'src/extensions/file_helpers.dart'; diff --git a/lib/src/alfred_openapi.dart b/lib/src/alfred_openapi.dart index 41fdd47..46838ce 100644 --- a/lib/src/alfred_openapi.dart +++ b/lib/src/alfred_openapi.dart @@ -1,6 +1,7 @@ import 'package:alfred/alfred.dart'; import 'package:yaml_writer/yaml_writer.dart'; +/// Enum to define the OpenAPI types enum OpenAPIType { string, number, @@ -14,6 +15,7 @@ enum OpenAPIType { } } +/// Enum to define the OpenAPI content types enum OpenAPIContentType { object, array; @@ -70,7 +72,33 @@ extension AlfredOpenAPI on Alfred { '200': { 'description': 'Success', } - } + }, + if (route.openAPIDoc?.request != null) + 'requestBody': { + 'content': { + route.openAPIDoc!.request!.contentType: { + 'schema': { + 'type': route.openAPIDoc!.request!.content.toJson(), + if (route.openAPIDoc!.request!.content == + OpenAPIContentType.object) + 'properties': { + for (var item in route.openAPIDoc!.request!.schema) + ...item.toJson(), + }, + if (route.openAPIDoc!.request!.content == + OpenAPIContentType.array) + 'items': { + 'type': route.openAPIDoc!.request!.schema.first.type + .toJson(), + if (route.openAPIDoc!.request!.schema.first.example != + null) + 'example': + route.openAPIDoc!.request!.schema.first.example, + }, + }, + }, + }, + }, } } }); @@ -85,11 +113,26 @@ class OpenAPIDoc { final String title; final String? description; final List responses; + final OpenAPIRequest? request; OpenAPIDoc({ required this.title, this.description, required this.responses, + this.request, + }); +} + +/// Class to define the OpenAPI request +class OpenAPIRequest { + final String contentType; + final OpenAPIContentType content; + final List schema; + + OpenAPIRequest({ + this.contentType = 'application/json', + this.content = OpenAPIContentType.object, + required this.schema, }); } @@ -140,17 +183,20 @@ class OpenAPIResponseContent { final String key; final OpenAPIType type; final dynamic example; + final bool required; OpenAPIResponseContent({ required this.key, required this.type, this.example, + this.required = true, }); Map toJson() { return { key: { 'type': type.toJson(), + // 'required': required, if (example != null) 'example': example, } }; diff --git a/lib/src/http_route.dart b/lib/src/http_route.dart index ca412a4..c4a4f3a 100644 --- a/lib/src/http_route.dart +++ b/lib/src/http_route.dart @@ -1,7 +1,5 @@ import 'dart:async'; -import 'package:alfred/src/alfred_openapi.dart'; - import '../alfred.dart'; class HttpRoute { @@ -10,7 +8,7 @@ class HttpRoute { /// The OpenAPI documentation for this route final OpenAPIDoc? openAPIDoc; - + final FutureOr Function(HttpRequest req, HttpResponse res) callback; final List middleware;