diff --git a/packages/pharaoh/lib/src/_next/_core/core_impl.dart b/packages/pharaoh/lib/src/_next/_core/core_impl.dart index b6ac958c..c2cc233d 100644 --- a/packages/pharaoh/lib/src/_next/_core/core_impl.dart +++ b/packages/pharaoh/lib/src/_next/_core/core_impl.dart @@ -20,6 +20,30 @@ class _PharaohNextImpl implements Application { void useRoutes(RoutesResolver routeResolver) { final routes = routeResolver.call(); routes.forEach((route) => route.commit(_spanner)); + + final openAPiRoutes = routes.fold( + [], (preV, curr) => preV..addAll(curr.openAPIRoutes)); + + final result = OpenApiGenerator.generateOpenApi( + openAPiRoutes, + apiName: _appConfig.name, + serverUrls: [_appConfig.url], + ); + + final openApiFile = File('openapi.json'); + openApiFile.writeAsStringSync(JsonEncoder.withIndent(' ').convert(result)); + + Route.route(HTTPMethod.GET, '/swagger', (req, res) { + return res + .header(HttpHeaders.contentTypeHeader, ContentType.html.value) + .send(OpenApiGenerator.renderDocsPage('/swagger.json')); + }).commit(_spanner); + + Route.route(HTTPMethod.GET, '/swagger.json', (_, res) { + return res + .header(HttpHeaders.contentTypeHeader, ContentType.json.value) + .send(openApiFile.openRead()); + }).commit(_spanner); } @override diff --git a/packages/pharaoh/lib/src/_next/_core/reflector.dart b/packages/pharaoh/lib/src/_next/_core/reflector.dart index 3148d0e0..51f817d5 100644 --- a/packages/pharaoh/lib/src/_next/_core/reflector.dart +++ b/packages/pharaoh/lib/src/_next/_core/reflector.dart @@ -99,11 +99,14 @@ ControllerMethod parseControllerMethod(ControllerMethodDefinition defn) { methods.firstWhereOrNull((e) => e.simpleName == symbolToString(method)); if (actualMethod == null) { throw ArgumentError( - '$type does not have method #${symbolToString(method)}'); + '$type does not have method #${symbolToString(method)}', + ); } + final returnType = getActualType(actualMethod.reflectedReturnType); + final parameters = actualMethod.parameters; - if (parameters.isEmpty) return ControllerMethod(defn); + if (parameters.isEmpty) return ControllerMethod(defn, returnType: returnType); if (parameters.any((e) => e.metadata.length > 1)) { throw ArgumentError( @@ -131,7 +134,17 @@ ControllerMethod parseControllerMethod(ControllerMethodDefinition defn) { ); }); - return ControllerMethod(defn, params); + return ControllerMethod(defn, params: params, returnType: returnType); +} + +final _regex = RegExp(r"^(\w+)<(.+)>$"); +Type? getActualType(Type type) { + final match = _regex.firstMatch(type.toString()); + if (match != null) { + return q.data[inject]!.types + .firstWhereOrNull((type) => type.toString() == match.group(2)); + } + return type; } BaseDTO? _tryResolveDtoInstance(Type type) { diff --git a/packages/pharaoh/lib/src/_next/_router/definition.dart b/packages/pharaoh/lib/src/_next/_router/definition.dart index 01a4e7f5..29297a6f 100644 --- a/packages/pharaoh/lib/src/_next/_router/definition.dart +++ b/packages/pharaoh/lib/src/_next/_router/definition.dart @@ -23,14 +23,26 @@ class RouteMapping { } } +typedef OpenApiRoute = ({ + List tags, + Type? returnType, + HTTPMethod method, + String route, + List args, +}); + abstract class RouteDefinition { late RouteMapping route; final RouteDefinitionType type; + String? group; + RouteDefinition(this.type); void commit(Spanner spanner); + List get openAPIRoutes; + RouteDefinition _prefix(String prefix) => this..route = route.prefix(prefix); } @@ -53,7 +65,8 @@ class UseAliasedMiddleware { RouteGroupDefinition routes(List routes) { return RouteGroupDefinition._( - BASE_PATH, + alias, + prefix: BASE_PATH, definitions: routes, )..middleware(mdw); } @@ -69,11 +82,15 @@ class _MiddlewareDefinition extends RouteDefinition { @override void commit(Spanner spanner) => spanner.addMiddleware(route.path, mdw); + + @override + List get openAPIRoutes => const []; } typedef ControllerMethodDefinition = (Type controller, Symbol symbol); class ControllerMethod { + final Type? returnType; final ControllerMethodDefinition method; final Iterable params; @@ -81,7 +98,11 @@ class ControllerMethod { Type get controller => method.$1; - ControllerMethod(this.method, [this.params = const []]); + ControllerMethod( + this.method, { + this.params = const [], + this.returnType, + }); } class ControllerMethodParam { @@ -121,6 +142,17 @@ class ControllerRouteMethodDefinition extends RouteDefinition { spanner.addRoute(routeMethod, route.path, useRequestHandler(handler)); } } + + @override + List get openAPIRoutes => route.methods + .map((e) => ( + route: route.path, + method: e, + args: method.params.toList(), + returnType: method.returnType, + tags: [if (group != null) group!] + )) + .toList(); } class RouteGroupDefinition extends RouteDefinition { @@ -146,12 +178,12 @@ class RouteGroupDefinition extends RouteDefinition { void _unwrapRoutes(Iterable routes) { for (final subRoute in routes) { if (subRoute is! RouteGroupDefinition) { - defns.add(subRoute._prefix(route.path)); + defns.add(subRoute._prefix(route.path)..group = name); continue; } for (var e in subRoute.defns) { - defns.add(e._prefix(route.path)); + defns.add(e._prefix(route.path)..group = subRoute.name); } } } @@ -169,6 +201,12 @@ class RouteGroupDefinition extends RouteDefinition { mdw.commit(spanner); } } + + @override + List get openAPIRoutes => defns.fold( + [], + (preV, c) => preV..addAll(c.openAPIRoutes), + ); } typedef RequestHandlerWithApp = Function( @@ -208,4 +246,15 @@ class FunctionalRouteDefinition extends RouteDefinition { spanner.addRoute(method, path, _requestHandler!); } } + + @override + List get openAPIRoutes => [ + ( + args: [], + returnType: Response, + method: method, + route: route.path, + tags: [if (group != null) group!] + ) + ]; } diff --git a/packages/pharaoh/lib/src/_next/_router/meta.dart b/packages/pharaoh/lib/src/_next/_router/meta.dart index 43c334ea..4670aa87 100644 --- a/packages/pharaoh/lib/src/_next/_router/meta.dart +++ b/packages/pharaoh/lib/src/_next/_router/meta.dart @@ -1,6 +1,6 @@ part of '../router.dart'; -abstract class RequestAnnotation { +sealed class RequestAnnotation { final String? name; const RequestAnnotation([this.name]); @@ -61,7 +61,7 @@ class Body extends RequestAnnotation { } final dtoInstance = methodParam.dto; - if (dtoInstance != null) return dtoInstance..make(request); + if (dtoInstance != null) return dtoInstance..validate(request); final type = methodParam.type; if (type != dynamic && body.runtimeType != type) { diff --git a/packages/pharaoh/lib/src/_next/_validation/dto.dart b/packages/pharaoh/lib/src/_next/_validation/dto.dart index 5b3f1b16..300af9a2 100644 --- a/packages/pharaoh/lib/src/_next/_validation/dto.dart +++ b/packages/pharaoh/lib/src/_next/_validation/dto.dart @@ -20,7 +20,7 @@ const dtoReflector = DtoReflector(); abstract interface class _BaseDTOImpl { late Map data; - void make(Request request) { + void validate(Request request) { data = const {}; final (result, errors) = schema.validateSync(request.body ?? {}); if (errors.isNotEmpty) { @@ -29,15 +29,12 @@ abstract interface class _BaseDTOImpl { data = Map.from(result); } - EzSchema? _schemaCache; - - EzSchema get schema { - if (_schemaCache != null) return _schemaCache!; - - final mirror = dtoReflector.reflectType(runtimeType) as r.ClassMirror; - final properties = mirror.getters.where((e) => e.isAbstract); - - final entries = properties.map((prop) { + r.ClassMirror? _classMirrorCache; + Iterable<({String name, Type type, ClassPropertyValidator meta})> + get properties { + _classMirrorCache ??= + dtoReflector.reflectType(runtimeType) as r.ClassMirror; + return _classMirrorCache!.getters.where((e) => e.isAbstract).map((prop) { final returnType = prop.reflectedReturnType; final meta = prop.metadata.whereType().firstOrNull ?? @@ -48,11 +45,23 @@ abstract interface class _BaseDTOImpl { 'Type Mismatch between ${meta.runtimeType}(${meta.propertyType}) & $runtimeType class property ${prop.simpleName}->($returnType)'); } - return MapEntry(meta.name ?? prop.simpleName, meta.validator); + return ( + name: (meta.name ?? prop.simpleName), + meta: meta, + type: returnType, + ); }); + } + + EzSchema? _schemaCache; + EzSchema get schema { + if (_schemaCache != null) return _schemaCache!; + + final entriesToMap = properties.fold>>( + {}, + (prev, curr) => prev..[curr.name] = curr.meta.validator, + ); - final entriesToMap = entries.fold>>( - {}, (prev, curr) => prev..[curr.key] = curr.value); return _schemaCache = EzSchema.shape(entriesToMap); } } diff --git a/packages/pharaoh/lib/src/_next/core.dart b/packages/pharaoh/lib/src/_next/core.dart index c857ae3a..11abefea 100644 --- a/packages/pharaoh/lib/src/_next/core.dart +++ b/packages/pharaoh/lib/src/_next/core.dart @@ -6,6 +6,7 @@ import 'dart:io'; import 'package:pharaoh/pharaoh.dart'; import 'package:reflectable/reflectable.dart' as r; +import 'package:reflectable/src/reflectable_builder_based.dart' as q; import 'package:spanner/spanner.dart'; import 'package:spookie/spookie.dart' as spookie; import 'package:collection/collection.dart'; @@ -14,6 +15,7 @@ import 'package:get_it/get_it.dart'; import 'package:meta/meta.dart'; import 'http.dart'; +import 'openapi.dart'; import 'router.dart'; import 'validation.dart'; @@ -112,28 +114,28 @@ abstract class ApplicationFactory { final spanner = Spanner()..addMiddleware('/', bodyParser); Application._instance = _PharaohNextImpl(config, spanner); - final providerInstances = providers.map(createNewInstance); + final providerInstances = providers + .map(createNewInstance) + .toList(growable: false); /// register dependencies - for (final instance in providerInstances) { - await Future.sync(instance.register); - } + await providerInstances + .map((provider) => Future.sync(provider.register)) + .wait; if (globalMiddleware != null) { spanner.addMiddleware('/', globalMiddleware!); } /// boot providers - for (final provider in providerInstances) { - await Future.sync(provider.boot); - } + await providerInstances.map((provider) => Future.sync(provider.boot)).wait; } static RequestHandler buildControllerMethod(ControllerMethod method) { final params = method.params; + final methodName = method.methodName; - return (req, res) { - final methodName = method.methodName; + return (req, res) async { final instance = createNewInstance(method.controller); final mirror = inject.reflect(instance); @@ -141,7 +143,7 @@ abstract class ApplicationFactory { ..invokeSetter('request', req) ..invokeSetter('response', res); - late Function() methodCall; + Function methodCall; if (params.isNotEmpty) { final args = _resolveControllerMethodArgs(req, method); @@ -150,7 +152,12 @@ abstract class ApplicationFactory { methodCall = () => mirror.invoke(methodName, []); } - return Future.sync(methodCall); + try { + final result = await methodCall.call(); + return result is Response ? result : res.json(result); + } on Response catch (response) { + return response; + } }; } diff --git a/packages/pharaoh/lib/src/_next/openapi.dart b/packages/pharaoh/lib/src/_next/openapi.dart new file mode 100644 index 00000000..6384fe95 --- /dev/null +++ b/packages/pharaoh/lib/src/_next/openapi.dart @@ -0,0 +1,210 @@ +import 'package:collection/collection.dart'; +import 'package:pharaoh/pharaoh_next.dart'; + +class OpenApiGenerator { + static Map generateOpenApi( + List routes, { + required String apiName, + required List serverUrls, + }) { + return { + "openapi": "3.0.0", + "info": {"title": apiName, "version": "1.0.0"}, + "servers": serverUrls.map((e) => {'url': e}).toList(), + "paths": _generatePaths(routes), + "components": {"schemas": _generateSchemas(routes)} + }; + } + + static Map _generatePaths(List routes) { + final paths = >{}; + + for (final route in routes) { + final pathParams = route.args.where((e) => e.meta is Param).toList(); + final bodyParam = route.args.firstWhereOrNull((e) => e.meta is Body); + final parameters = _generateParameters(route.args); + final routeMethod = route.method.name.toLowerCase(); + + var path = route.route; + + // Convert Express-style path params (:id) to OpenAPI style ({id}) + for (final param in pathParams) { + path = path.replaceAll('<${param.name}>', '{${param.name}}'); + } + + paths[path] = paths[path] ?? {}; + paths[path]![routeMethod] = { + "summary": "", + if (parameters.isNotEmpty) "parameters": parameters, + if (route.tags.isNotEmpty) "tags": route.tags, + "responses": { + "200": { + "description": "Successful response", + if (route.returnType != null && route.returnType != Response) + "content": { + "application/json": { + "schema": { + "\$ref": "#/components/schemas/${route.returnType}" + }, + }, + } + } + } + }; + + if (bodyParam != null) { + paths[path]![routeMethod]["requestBody"] = { + "required": !bodyParam.optional, + "content": { + "application/json": {"schema": _generateSchema(bodyParam)} + } + }; + } + } + + return paths; + } + + static List> _generateParameters( + List args, + ) { + final parameters = >[]; + + for (final arg in args) { + final parameterLocation = _getParameterLocation(arg.meta); + if (parameterLocation == null) continue; + + final parameterSchema = _generateSchema(arg); + + // Add default value if available and not a path parameter + if (arg.defaultValue != null && parameterLocation != "path") { + parameterSchema["default"] = arg.defaultValue; + } + + final param = { + "name": arg.name, + "in": parameterLocation, + "required": parameterLocation == "path" ? true : !arg.optional, + "schema": parameterSchema, + }; + + parameters.add(param); + } + + return parameters; + } + + static String? _getParameterLocation(RequestAnnotation? annotation) { + return switch (annotation) { + const Header() => "header", + const Query() => "query", + const Param() => "path", + _ => null, + }; + } + + static Map _generateSchema(ControllerMethodParam param) { + if (param.dto != null) { + return { + "\$ref": "#/components/schemas/${param.dto.runtimeType.toString()}" + }; + } + + return _typeToOpenApiType(param.type); + } + + static Map _typeToOpenApiType(Type type) { + switch (type) { + case const (String): + return {"type": "string"}; + case const (int): + return {"type": "integer", "format": "int32"}; + case const (double): + return {"type": "number", "format": "double"}; + case const (bool): + return {"type": "boolean"}; + case const (DateTime): + return {"type": "string", "format": "date-time"}; + default: + final actualType = getActualType(type); + if (actualType == null) return {"type": "object"}; + + // final properties = []; + + // ClassMirror? clazz = reflectType(actualType); + // while (clazz?.superclass != null) { + // properties.addAll(clazz!.variables); + // clazz = clazz.superclass; + // } + + // print(properties); + + return {"type": "object"}; + } + } + + static Map _generateSchemas(List routes) { + final schemas = {}; + + for (final route in routes) { + final returnType = route.returnType; + for (final arg in route.args) { + final dto = arg.dto; + if (dto == null) continue; + + schemas[dto.runtimeType.toString()] = { + "type": "object", + "properties": dto.properties.fold({}, + (preV, curr) => preV..[curr.name] = _typeToOpenApiType(curr.type)) + }; + } + + if (returnType == null || returnType == Response) continue; + + final properties = reflectType(returnType).variables; + + schemas[returnType.toString()] = { + "type": "object", + "properties": properties.fold( + {}, + (preV, curr) => preV + ..[curr.simpleName] = _typeToOpenApiType(curr.reflectedType)) + }; + } + + return schemas; + } + + static String renderDocsPage(String openApiRoute) { + return ''' + + + + + + + SwaggerUI + + + +
+ + + + + +'''; + } +} diff --git a/packages/pharaoh/lib/src/_next/router.dart b/packages/pharaoh/lib/src/_next/router.dart index 33ab43a2..6f002b40 100644 --- a/packages/pharaoh/lib/src/_next/router.dart +++ b/packages/pharaoh/lib/src/_next/router.dart @@ -73,9 +73,13 @@ abstract interface class Route { return ControllerRouteMethodDefinition(defn, mapping); } - static RouteGroupDefinition group(String name, List routes, - {String? prefix}) => - RouteGroupDefinition._(name, definitions: routes, prefix: prefix); + static RouteGroupDefinition group( + String name, + List routes, { + String? prefix, + }) { + return RouteGroupDefinition._(name, definitions: routes, prefix: prefix); + } static RouteGroupDefinition resource(String resource, Type controller, {String? parameterName}) { @@ -103,6 +107,9 @@ abstract interface class Route { Route.route(method, '/*', handler); } -Middleware useAliasedMiddleware(String alias) => - ApplicationFactory.resolveMiddlewareForGroup(alias) - .reduce((val, e) => val.chain(e)); +@inject +abstract mixin class ApiResource { + const ApiResource(); + + Map toJson(); +} diff --git a/packages/pharaoh/test/pharaoh_next/core/application_factory_test.dart b/packages/pharaoh/test/pharaoh_next/core/application_factory_test.dart index 0ca92747..67db7673 100644 --- a/packages/pharaoh/test/pharaoh_next/core/application_factory_test.dart +++ b/packages/pharaoh/test/pharaoh_next/core/application_factory_test.dart @@ -35,7 +35,7 @@ void main() { test('for method with args', () async { final showMethod = ControllerMethod( (TestHttpController, #show), - [ControllerMethodParam('userId', int, meta: query)], + params: [ControllerMethodParam('userId', int, meta: query)], ); final handler = ApplicationFactory.buildControllerMethod(showMethod); diff --git a/packages/pharaoh/test/pharaoh_next/validation/validation_test.dart b/packages/pharaoh/test/pharaoh_next/validation/validation_test.dart index 9046713e..3d632794 100644 --- a/packages/pharaoh/test/pharaoh_next/validation/validation_test.dart +++ b/packages/pharaoh/test/pharaoh_next/validation/validation_test.dart @@ -114,7 +114,7 @@ void main() { final app = pharaoh ..post('/', (req, res) { - dto.make(req); + dto.validate(req); return res.json({ 'firstname': dto.username, 'lastname': dto.lastname, @@ -147,7 +147,7 @@ void main() { final app = pharaoh ..post('/optional', (req, res) { - dto.make(req); + dto.validate(req); return res.json({ 'nationality': dto.nationality, @@ -197,7 +197,7 @@ void main() { final dto = DTOTypeMismatch(); pharaoh.post('/type-mismatch', (req, res) { - dto.make(req); + dto.validate(req); return res.ok('Foo Bar'); });