diff --git a/example/example_open_api.dart b/example/example_open_api.dart new file mode 100644 index 0000000..9fab3ee --- /dev/null +++ b/example/example_open_api.dart @@ -0,0 +1,63 @@ +import 'package:alfred/alfred.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.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( + title: "Test API", + description: "Test API Description", + version: "1.2.3", + ); + }); + + await app.listen(); +} 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 new file mode 100644 index 0000000..46838ce --- /dev/null +++ b/lib/src/alfred_openapi.dart @@ -0,0 +1,204 @@ +import 'package:alfred/alfred.dart'; +import 'package:yaml_writer/yaml_writer.dart'; + +/// Enum to define the OpenAPI types +enum OpenAPIType { + string, + number, + integer, + boolean, + array, + object; + + String toJson() { + return toString().split('.').last; + } +} + +/// Enum to define the OpenAPI content types +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', + } + }, + 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, + }, + }, + }, + }, + }, + } + } + }); + } + + return specsYaml.write(specs).replaceAll('- /', '/'); + } +} + +/// Class to define the OpenAPI documentation +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, + }); +} + +/// 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; + 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 3207b14..c4a4f3a 100644 --- a/lib/src/http_route.dart +++ b/lib/src/http_route.dart @@ -5,6 +5,10 @@ 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 +24,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