From 71860550fbc2a3f307de011c4c420a0e3536d21f Mon Sep 17 00:00:00 2001 From: Chima Precious Date: Tue, 5 Mar 2024 09:32:53 +0000 Subject: [PATCH] feat: Implement pharaoh framework (#120) * implement pharaoh next * add tests for pharaoh next * fix lint issues * ignore generated files * run generator * damn * :) * tiny fix --- .github/workflows/test.yaml | 5 +- packages/pharaoh/.gitignore | 3 +- packages/pharaoh/lib/next/_core/config.dart | 70 +++++ .../pharaoh/lib/next/_core/container.dart | 18 ++ .../pharaoh/lib/next/_core/core_impl.dart | 47 +++ .../pharaoh/lib/next/_core/reflector.dart | 157 ++++++++++ .../pharaoh/lib/next/_router/definition.dart | 187 +++++++++++ packages/pharaoh/lib/next/_router/meta.dart | 162 ++++++++++ packages/pharaoh/lib/next/_router/utils.dart | 10 + .../pharaoh/lib/next/_validation/dto.dart | 39 +++ .../lib/next/_validation/dto_impl.dart | 43 +++ .../pharaoh/lib/next/_validation/meta.dart | 102 ++++++ packages/pharaoh/lib/next/core.dart | 205 ++++++++++++ packages/pharaoh/lib/next/http.dart | 69 +++++ packages/pharaoh/lib/next/router.dart | 111 +++++++ packages/pharaoh/lib/next/validation.dart | 2 + packages/pharaoh/lib/pharaoh.dart | 2 +- packages/pharaoh/lib/src/core_impl.dart | 2 +- packages/pharaoh/lib/src/http/message.dart | 2 +- packages/pharaoh/lib/src/http/response.dart | 4 +- .../pharaoh/lib/src/http/response_impl.dart | 10 +- .../pharaoh/lib/src/shelf_interop/shelf.dart | 4 +- packages/pharaoh/lib/src/view/view.dart | 2 +- packages/pharaoh/pubspec.yaml | 11 +- .../test/pharaoh_next/config/config_test.dart | 37 +++ .../core/application_factory_test.dart | 56 ++++ .../test/pharaoh_next/core/core_test.dart | 113 +++++++ .../test/pharaoh_next/http/meta_test.dart | 293 ++++++++++++++++++ .../test/pharaoh_next/router_test.dart | 206 ++++++++++++ .../validation/validation_test.dart | 216 +++++++++++++ 30 files changed, 2173 insertions(+), 15 deletions(-) create mode 100644 packages/pharaoh/lib/next/_core/config.dart create mode 100644 packages/pharaoh/lib/next/_core/container.dart create mode 100644 packages/pharaoh/lib/next/_core/core_impl.dart create mode 100644 packages/pharaoh/lib/next/_core/reflector.dart create mode 100644 packages/pharaoh/lib/next/_router/definition.dart create mode 100644 packages/pharaoh/lib/next/_router/meta.dart create mode 100644 packages/pharaoh/lib/next/_router/utils.dart create mode 100644 packages/pharaoh/lib/next/_validation/dto.dart create mode 100644 packages/pharaoh/lib/next/_validation/dto_impl.dart create mode 100644 packages/pharaoh/lib/next/_validation/meta.dart create mode 100644 packages/pharaoh/lib/next/core.dart create mode 100644 packages/pharaoh/lib/next/http.dart create mode 100644 packages/pharaoh/lib/next/router.dart create mode 100644 packages/pharaoh/lib/next/validation.dart create mode 100644 packages/pharaoh/test/pharaoh_next/config/config_test.dart create mode 100644 packages/pharaoh/test/pharaoh_next/core/application_factory_test.dart create mode 100644 packages/pharaoh/test/pharaoh_next/core/core_test.dart create mode 100644 packages/pharaoh/test/pharaoh_next/http/meta_test.dart create mode 100644 packages/pharaoh/test/pharaoh_next/router_test.dart create mode 100644 packages/pharaoh/test/pharaoh_next/validation/validation_test.dart diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 8a249383..4734486a 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -31,7 +31,9 @@ jobs: run: melos format -- --set-exit-if-changed - name: Check linting - run: melos analyze + run: | + cd packages/pharaoh && dart run build_runner build --delete-conflicting-outputs + melos analyze test: name: Test Packages @@ -47,6 +49,7 @@ jobs: run: | dart pub global activate melos melos bootstrap + cd packages/pharaoh && dart run build_runner build --delete-conflicting-outputs - name: Run Unit tests run: melos tests:ci diff --git a/packages/pharaoh/.gitignore b/packages/pharaoh/.gitignore index 701c328e..08151147 100644 --- a/packages/pharaoh/.gitignore +++ b/packages/pharaoh/.gitignore @@ -6,4 +6,5 @@ # https://dart.dev/guides/libraries/private-files#pubspeclock. pubspec.lock .idea/ -**/pubspec_overrides.yaml \ No newline at end of file +**/pubspec_overrides.yaml +**.reflectable.dart diff --git a/packages/pharaoh/lib/next/_core/config.dart b/packages/pharaoh/lib/next/_core/config.dart new file mode 100644 index 00000000..3b666911 --- /dev/null +++ b/packages/pharaoh/lib/next/_core/config.dart @@ -0,0 +1,70 @@ +import 'dart:convert'; + +import 'package:dotenv/dotenv.dart'; + +DotEnv? _env; + +T env(String name, T defaultValue) { + _env ??= DotEnv(quiet: true, includePlatformEnvironment: true)..load(); + final strVal = _env![name]; + if (strVal == null) return defaultValue; + + final parsedVal = switch (T) { + const (String) => strVal, + const (int) => int.parse(strVal), + const (num) => num.parse(strVal), + const (bool) => bool.parse(strVal), + const (double) => double.parse(strVal), + const (List) => jsonDecode(strVal), + _ => throw ArgumentError.value( + T, null, 'Unsupported Type used in `env` call.'), + }; + return parsedVal as T; +} + +extension ConfigExtension on Map { + T getValue(String name, {T? defaultValue, bool allowEmpty = false}) { + final value = this[name] ?? defaultValue; + if (value is! T) { + throw ArgumentError.value( + value, null, 'Invalid value provided for $name'); + } + if (value != null && value.toString().trim().isEmpty && !allowEmpty) { + throw ArgumentError.value( + value, null, 'Empty value not allowed for $name'); + } + return value; + } +} + +class AppConfig { + final String name; + final String environment; + final bool isDebug; + final String timezone; + final String locale; + final String key; + final int? _port; + final String? _url; + + Uri get _uri { + final uri = Uri.parse(_url!); + return _port == null ? uri : uri.replace(port: _port); + } + + int get port => _uri.port; + + String get url => _uri.toString(); + + const AppConfig({ + required this.name, + required this.environment, + required this.isDebug, + required this.key, + this.timezone = 'UTC', + this.locale = 'en', + int? port, + String? url, + }) : _port = port, + _url = url; +} diff --git a/packages/pharaoh/lib/next/_core/container.dart b/packages/pharaoh/lib/next/_core/container.dart new file mode 100644 index 00000000..3a876c32 --- /dev/null +++ b/packages/pharaoh/lib/next/_core/container.dart @@ -0,0 +1,18 @@ +import 'package:get_it/get_it.dart'; + +final GetIt _getIt = GetIt.instance; + +T instanceFromRegistry({Type? type}) { + type ??= T; + try { + return _getIt.get(type: type) as T; + } catch (_) { + throw Exception('Dependency not found in registry: $type'); + } +} + +T registerSingleton(T instance) { + return _getIt.registerSingleton(instance); +} + +bool isRegistered() => _getIt.isRegistered(); diff --git a/packages/pharaoh/lib/next/_core/core_impl.dart b/packages/pharaoh/lib/next/_core/core_impl.dart new file mode 100644 index 00000000..3b097969 --- /dev/null +++ b/packages/pharaoh/lib/next/_core/core_impl.dart @@ -0,0 +1,47 @@ +// ignore_for_file: avoid_function_literals_in_foreach_calls + +part of '../core.dart'; + +class _PharaohNextImpl implements Application { + late final AppConfig _appConfig; + late final Spanner _spanner; + + ViewEngine? _viewEngine; + + _PharaohNextImpl(this._appConfig, this._spanner); + + @override + T singleton(T instance) => registerSingleton(instance); + + @override + T instanceOf() => instanceFromRegistry(); + + @override + void useRoutes(RoutesResolver routeResolver) { + final routes = routeResolver.call(); + routes.forEach((route) => route.commit(_spanner)); + } + + @override + void useViewEngine(ViewEngine viewEngine) => _viewEngine = viewEngine; + + @override + AppConfig get config => _appConfig; + + @override + String get name => config.name; + + @override + String get url => config.url; + + @override + int get port => config.port; + + Pharaoh _createPharaohInstance({OnErrorCallback? onException}) { + final pharaoh = Pharaoh() + ..useSpanner(_spanner) + ..viewEngine = _viewEngine; + if (onException != null) pharaoh.onError(onException); + return pharaoh; + } +} diff --git a/packages/pharaoh/lib/next/_core/reflector.dart b/packages/pharaoh/lib/next/_core/reflector.dart new file mode 100644 index 00000000..2c2d8f6c --- /dev/null +++ b/packages/pharaoh/lib/next/_core/reflector.dart @@ -0,0 +1,157 @@ +import 'package:collection/collection.dart'; +import 'package:reflectable/reflectable.dart' as r; + +import 'container.dart'; +import '../router.dart'; +import '../_router/utils.dart'; +import '../_validation/dto.dart'; +import '../http.dart'; + +class Injectable extends r.Reflectable { + const Injectable() + : super( + r.invokingCapability, + r.metadataCapability, + r.newInstanceCapability, + r.declarationsCapability, + r.reflectedTypeCapability, + r.typeRelationsCapability, + const r.InstanceInvokeCapability('^[^_]'), + r.subtypeQuantifyCapability, + ); +} + +const unnamedConstructor = ''; + +const inject = Injectable(); + +List filteredDeclarationsOf( + r.ClassMirror cm, predicate) { + var result = []; + cm.declarations.forEach((k, v) { + if (predicate(v)) result.add(v as X); + }); + return result; +} + +r.ClassMirror reflectType(Type type) { + try { + return inject.reflectType(type) as r.ClassMirror; + } catch (e) { + throw UnsupportedError( + 'Unable to reflect on $type. Re-run your build command'); + } +} + +extension ClassMirrorExtensions on r.ClassMirror { + List get variables { + return filteredDeclarationsOf(this, (v) => v is r.VariableMirror); + } + + List get getters { + return filteredDeclarationsOf( + this, (v) => v is r.MethodMirror && v.isGetter); + } + + List get setters { + return filteredDeclarationsOf( + this, (v) => v is r.MethodMirror && v.isSetter); + } + + List get methods { + return filteredDeclarationsOf( + this, (v) => v is r.MethodMirror && v.isRegularMethod); + } +} + +T createNewInstance(Type classType) { + final classMirror = reflectType(classType); + final constructorMethod = classMirror.declarations.entries + .firstWhereOrNull((e) => e.key == '$classType') + ?.value as r.MethodMirror?; + final constructorParameters = constructorMethod?.parameters ?? []; + if (constructorParameters.isEmpty) { + return classMirror.newInstance(unnamedConstructor, const []) as T; + } + + final namedDeps = constructorParameters + .where((e) => e.isNamed) + .map((e) => ( + name: e.simpleName, + instance: instanceFromRegistry(type: e.reflectedType) + )) + .fold>( + {}, (prev, e) => prev..[Symbol(e.name)] = e.instance); + + final dependencies = constructorParameters + .where((e) => !e.isNamed) + .map((e) => instanceFromRegistry(type: e.reflectedType)) + .toList(); + + return classMirror.newInstance(unnamedConstructor, dependencies, namedDeps) + as T; +} + +ControllerMethod parseControllerMethod(ControllerMethodDefinition defn) { + final type = defn.$1; + final method = defn.$2; + + final ctrlMirror = inject.reflectType(type) as r.ClassMirror; + if (ctrlMirror.superclass?.reflectedType != HTTPController) { + throw ArgumentError('$type must extend BaseController'); + } + + final methods = ctrlMirror.instanceMembers.values.whereType(); + final actualMethod = + methods.firstWhereOrNull((e) => e.simpleName == symbolToString(method)); + if (actualMethod == null) { + throw ArgumentError( + '$type does not have method #${symbolToString(method)}'); + } + + final parameters = actualMethod.parameters; + if (parameters.isEmpty) return ControllerMethod(defn); + + if (parameters.any((e) => e.metadata.length > 1)) { + throw ArgumentError( + 'Multiple annotations using on $type #${symbolToString(method)} parameter'); + } + + final params = parameters.map((e) { + final meta = e.metadata.first; + if (meta is! RequestAnnotation) { + throw ArgumentError( + 'Invalid annotation $meta used on $type #${symbolToString(method)} parameter'); + } + + final paramType = e.reflectedType; + final maybeDto = _tryResolveDtoInstance(paramType); + + return ControllerMethodParam(e.simpleName, paramType, + defaultValue: e.defaultValue, + optional: e.isOptional, + meta: meta, + dto: maybeDto); + }).toList(); + + return ControllerMethod(defn, params); +} + +BaseDTO? _tryResolveDtoInstance(Type type) { + try { + final mirror = dtoReflector.reflectType(type) as r.ClassMirror; + return mirror.newInstance(unnamedConstructor, []) as BaseDTO; + } on r.NoSuchCapabilityError catch (_) { + return null; + } +} + +void ensureIsSubTypeOf(Type objectType) { + try { + final type = reflectType(objectType); + if (type.superclass!.reflectedType != Parent) throw Exception(); + } catch (e) { + throw ArgumentError.value(objectType, 'Invalid Type provided', + 'Ensure your class extends `$Parent` class'); + } +} diff --git a/packages/pharaoh/lib/next/_router/definition.dart b/packages/pharaoh/lib/next/_router/definition.dart new file mode 100644 index 00000000..790f4451 --- /dev/null +++ b/packages/pharaoh/lib/next/_router/definition.dart @@ -0,0 +1,187 @@ +part of '../router.dart'; + +enum RouteDefinitionType { route, group, middleware } + +class RouteMapping { + final List methods; + final String _path; + + @visibleForTesting + String get stringVal => '${methods.map((e) => e.name).toList()}: $_path'; + + String get path => cleanRoute(_path); + + const RouteMapping(this.methods, this._path); + + RouteMapping prefix(String prefix) => RouteMapping(methods, '$prefix$_path'); +} + +abstract class RouteDefinition { + late RouteMapping route; + final RouteDefinitionType type; + + RouteDefinition(this.type); + + void commit(Spanner spanner); + + RouteDefinition _prefix(String prefix) => this..route = route.prefix(prefix); +} + +class UseAliasedMiddleware { + final String alias; + + UseAliasedMiddleware(this.alias); + + Iterable get mdw => + ApplicationFactory.resolveMiddlewareForGroup(alias); + + RouteGroupDefinition group( + String name, + List routes, { + String? prefix, + }) { + return RouteGroupDefinition._(name, prefix: prefix, definitions: routes) + ..middleware(mdw); + } +} + +class _MiddlewareDefinition extends RouteDefinition { + final Middleware mdw; + + _MiddlewareDefinition(this.mdw, RouteMapping route) + : super(RouteDefinitionType.middleware) { + this.route = route; + } + + @override + void commit(Spanner spanner) => spanner.addMiddleware(route.path, mdw); +} + +typedef ControllerMethodDefinition = (Type controller, Symbol symbol); + +class ControllerMethod { + final ControllerMethodDefinition method; + final List params; + + String get methodName => symbolToString(method.$2); + + Type get controller => method.$1; + + ControllerMethod(this.method, [this.params = const []]); +} + +class ControllerMethodParam { + final String name; + final Type type; + final bool optional; + final dynamic defaultValue; + final RequestAnnotation? meta; + + final BaseDTO? dto; + + const ControllerMethodParam(this.name, this.type, + {this.meta, this.optional = false, this.defaultValue, this.dto}); +} + +class ControllerRouteMethodDefinition extends RouteDefinition { + final ControllerMethod method; + + ControllerRouteMethodDefinition( + ControllerMethodDefinition defn, RouteMapping mapping) + : method = parseControllerMethod(defn), + super(RouteDefinitionType.route) { + route = mapping; + } + + @override + void commit(Spanner spanner) { + final handler = ApplicationFactory.buildControllerMethod(method); + for (final routeMethod in route.methods) { + spanner.addRoute(routeMethod, route.path, useRequestHandler(handler)); + } + } +} + +class RouteGroupDefinition extends RouteDefinition { + final String name; + final List defns = []; + + List get paths => defns.map((e) => e.route.stringVal).toList(); + + RouteGroupDefinition._( + this.name, { + String? prefix, + Iterable definitions = const [], + }) : super(RouteDefinitionType.group) { + route = RouteMapping([HTTPMethod.ALL], '/${prefix ?? name.toLowerCase()}'); + if (definitions.isEmpty) { + throw StateError('Route definitions not provided for group'); + } + _unwrapRoutes(definitions); + } + + void _unwrapRoutes(Iterable routes) { + for (final subRoute in routes) { + if (subRoute is! RouteGroupDefinition) { + defns.add(subRoute._prefix(route.path)); + continue; + } + + for (var e in subRoute.defns) { + defns.add(e._prefix(route.path)); + } + } + } + + void middleware(Iterable func) { + if (func.isEmpty) return; + final mdwDefn = + _MiddlewareDefinition(func.reduce((val, e) => val.chain(e)), route); + defns.insert(0, mdwDefn); + } + + @override + void commit(Spanner spanner) { + for (final mdw in defns) { + mdw.commit(spanner); + } + } +} + +typedef RequestHandlerWithApp = Function( + Application app, Request req, Response res); + +class FunctionalRouteDefinition extends RouteDefinition { + final HTTPMethod method; + final String path; + + final Middleware? _middleware; + final Middleware? _requestHandler; + + FunctionalRouteDefinition.route( + this.method, + this.path, + RequestHandler handler, + ) : _middleware = null, + _requestHandler = useRequestHandler(handler), + super(RouteDefinitionType.route) { + route = RouteMapping([method], path); + } + + FunctionalRouteDefinition.middleware(this.path, Middleware handler) + : _requestHandler = null, + _middleware = handler, + method = HTTPMethod.ALL, + super(RouteDefinitionType.middleware) { + route = RouteMapping([method], path); + } + + @override + void commit(Spanner spanner) { + if (_middleware != null) { + spanner.addMiddleware(path, _middleware!); + } else if (_requestHandler != null) { + spanner.addRoute(method, path, _requestHandler!); + } + } +} diff --git a/packages/pharaoh/lib/next/_router/meta.dart b/packages/pharaoh/lib/next/_router/meta.dart new file mode 100644 index 00000000..3887d5cf --- /dev/null +++ b/packages/pharaoh/lib/next/_router/meta.dart @@ -0,0 +1,162 @@ +import 'dart:convert'; + +import 'package:ez_validator/ez_validator.dart'; +import 'package:pharaoh/pharaoh.dart'; + +import '../router.dart'; + +abstract class RequestAnnotation { + final String? name; + + const RequestAnnotation([this.name]); + + T process(Request request, ControllerMethodParam methodParam); +} + +enum ValidationErrorLocation { param, query, body, header } + +class RequestValidationError extends Error { + final String message; + final Map? errors; + final ValidationErrorLocation location; + + RequestValidationError.param(this.message) + : location = ValidationErrorLocation.param, + errors = null; + + RequestValidationError.header(this.message) + : location = ValidationErrorLocation.header, + errors = null; + + RequestValidationError.query(this.message) + : location = ValidationErrorLocation.query, + errors = null; + + RequestValidationError.body(this.message) + : location = ValidationErrorLocation.body, + errors = null; + + RequestValidationError.errors(this.location, this.errors) : message = ''; + + Map get errorBody => { + 'location': location.name, + if (errors != null) + 'errors': errors!.entries.map((e) => '${e.key}: ${e.value}').toList(), + if (message.isNotEmpty) 'errors': [message], + }; + + @override + String toString() => errorBody.toString(); +} + +/// Use this to annotate a parameter in a controller method +/// which will be resolved to the request body. +/// +/// Example: create(@Body() user) {} +class Body extends RequestAnnotation { + const Body(); + + @override + process(Request request, ControllerMethodParam methodParam) { + final body = request.body; + if (body == null) { + if (methodParam.optional) return null; + throw RequestValidationError.body( + EzValidator.globalLocale.required('body')); + } + + final dtoInstance = methodParam.dto; + if (dtoInstance != null) return dtoInstance..make(request); + + final type = methodParam.type; + if (type != dynamic && body.runtimeType != type) { + throw RequestValidationError.body( + EzValidator.globalLocale.isTypeOf('${methodParam.type}', 'body')); + } + + return body; + } +} + +/// Use this to annotate a parameter in a controller method +/// which will be resolved to a parameter in the request path. +/// +/// `/users//details` Example: getUser(@Param() String userId) {} +class Param extends RequestAnnotation { + const Param([super.name]); + + @override + process(Request request, ControllerMethodParam methodParam) { + final paramName = name ?? methodParam.name; + final value = request.params[paramName] ?? methodParam.defaultValue; + final parsedValue = _parseValue(value, methodParam.type); + if (parsedValue == null) { + throw RequestValidationError.param( + EzValidator.globalLocale.isTypeOf('${methodParam.type}', paramName)); + } + return parsedValue; + } +} + +/// Use this to annotate a parameter in a controller method +/// which will be resolved to a parameter in the request query params. +/// +/// `/users?name=Chima` Example: searchUsers(@Query() String name) {} +class Query extends RequestAnnotation { + const Query([super.name]); + + @override + process(Request request, ControllerMethodParam methodParam) { + final paramName = name ?? methodParam.name; + final value = request.query[paramName] ?? methodParam.defaultValue; + if (!methodParam.optional && value == null) { + throw RequestValidationError.query( + EzValidator.globalLocale.required(paramName)); + } + + final parsedValue = _parseValue(value, methodParam.type); + if (parsedValue == null) { + throw RequestValidationError.query( + EzValidator.globalLocale.isTypeOf('${methodParam.type}', paramName)); + } + return parsedValue; + } +} + +class Header extends RequestAnnotation { + const Header([super.name]); + + @override + process(Request request, ControllerMethodParam methodParam) { + final paramName = name ?? methodParam.name; + final value = request.headers[paramName] ?? methodParam.defaultValue; + if (!methodParam.optional && value == null) { + throw RequestValidationError.header( + EzValidator.globalLocale.required(paramName)); + } + + final parsedValue = _parseValue(value, methodParam.type); + if (parsedValue == null) { + throw RequestValidationError.header( + EzValidator.globalLocale.isTypeOf('${methodParam.type}', paramName)); + } + return parsedValue; + } +} + +_parseValue(dynamic value, Type type) { + if (value.runtimeType == type) return value; + value = value.toString(); + return switch (type) { + const (int) => int.tryParse(value), + const (double) => double.tryParse(value), + const (bool) => value == 'true', + const (List) || const (Map) => jsonDecode(value), + _ => value, + }; +} + +const param = Param(); +const query = Query(); +const body = Body(); +const header = Header(); diff --git a/packages/pharaoh/lib/next/_router/utils.dart b/packages/pharaoh/lib/next/_router/utils.dart new file mode 100644 index 00000000..39d75dde --- /dev/null +++ b/packages/pharaoh/lib/next/_router/utils.dart @@ -0,0 +1,10 @@ +String cleanRoute(String route) { + final result = + route.replaceAll(RegExp(r'/+'), '/').replaceAll(RegExp(r'/$'), ''); + return result.isEmpty ? '/' : result; +} + +String symbolToString(Symbol symbol) { + final str = symbol.toString(); + return str.substring(8, str.length - 2); +} diff --git a/packages/pharaoh/lib/next/_validation/dto.dart b/packages/pharaoh/lib/next/_validation/dto.dart new file mode 100644 index 00000000..8f3420f4 --- /dev/null +++ b/packages/pharaoh/lib/next/_validation/dto.dart @@ -0,0 +1,39 @@ +import 'dart:collection'; + +import 'package:ez_validator/ez_validator.dart'; +import 'package:pharaoh/next/_core/reflector.dart'; +import 'package:pharaoh/pharaoh.dart'; +import 'package:reflectable/reflectable.dart' as r; +import 'package:meta/meta.dart'; + +import '../_router/meta.dart'; +import '../_router/utils.dart'; +import 'meta.dart'; + +part 'dto_impl.dart'; + +const _instanceInvoke = r.InstanceInvokeCapability('^[^_]'); + +class DtoReflector extends r.Reflectable { + const DtoReflector() + : super( + r.typeCapability, + r.metadataCapability, + r.newInstanceCapability, + r.declarationsCapability, + r.reflectedTypeCapability, + _instanceInvoke, + r.subtypeQuantifyCapability); +} + +@protected +const dtoReflector = DtoReflector(); + +@dtoReflector +abstract class BaseDTO extends _BaseDTOImpl { + @override + noSuchMethod(Invocation invocation) { + final property = symbolToString(invocation.memberName); + return _databag[property]; + } +} diff --git a/packages/pharaoh/lib/next/_validation/dto_impl.dart b/packages/pharaoh/lib/next/_validation/dto_impl.dart new file mode 100644 index 00000000..1afef1b5 --- /dev/null +++ b/packages/pharaoh/lib/next/_validation/dto_impl.dart @@ -0,0 +1,43 @@ +part of 'dto.dart'; + +abstract interface class _BaseDTOImpl { + final Map _databag = {}; + + Map get data => UnmodifiableMapView(_databag); + + void make(Request request) { + _databag.clear(); + final (data, errors) = schema.validateSync(request.body ?? {}); + if (errors.isNotEmpty) { + throw RequestValidationError.errors(ValidationErrorLocation.body, errors); + } + _databag.addAll(Map.from(data)); + } + + 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) { + final returnType = prop.reflectedReturnType; + final meta = + prop.metadata.whereType().firstOrNull ?? + ezRequired(returnType); + + if (meta.propertyType != returnType) { + throw ArgumentError( + 'Type Mismatch between ${meta.runtimeType}(${meta.propertyType}) & $runtimeType class property ${prop.simpleName}->($returnType)'); + } + + return MapEntry(meta.name ?? prop.simpleName, meta.validator); + }); + + final entriesToMap = entries.fold>>( + {}, (prev, curr) => prev..[curr.key] = curr.value); + return _schemaCache = EzSchema.shape(entriesToMap); + } +} diff --git a/packages/pharaoh/lib/next/_validation/meta.dart b/packages/pharaoh/lib/next/_validation/meta.dart new file mode 100644 index 00000000..9496a425 --- /dev/null +++ b/packages/pharaoh/lib/next/_validation/meta.dart @@ -0,0 +1,102 @@ +// ignore_for_file: camel_case_types + +import 'package:ez_validator/ez_validator.dart'; + +abstract class ClassPropertyValidator { + final String? name; + + /// TODO: we need to be able to infer nullability also from the type + /// we'll need reflection for that, tho currently, the reason i'm not + /// doing it is because of the amount of code the library (reflectable) + /// generates just to enable this capability + final bool optional; + + final T? defaultVal; + + Type get propertyType => T; + + const ClassPropertyValidator({ + this.name, + this.defaultVal, + this.optional = false, + }); + + EzValidator get validator { + final base = EzValidator(defaultValue: defaultVal, optional: optional); + return optional + ? base.isType(propertyType) + : base.required().isType(propertyType); + } +} + +class ezEmail extends ClassPropertyValidator { + final String? message; + + const ezEmail({super.name, super.defaultVal, super.optional, this.message}); + + @override + EzValidator get validator => super.validator.email(message); +} + +class ezDateTime extends ClassPropertyValidator { + final String? message; + + final DateTime? minDate, maxDate; + + const ezDateTime( + {super.name, + super.defaultVal, + super.optional, + this.message, + this.maxDate, + this.minDate}); + + @override + EzValidator get validator { + final base = super.validator.date(message); + if (minDate != null) return base.minDate(minDate!); + if (maxDate != null) return base.maxDate(maxDate!); + return base; + } +} + +class ezMinLength extends ClassPropertyValidator { + final int value; + + const ezMinLength(this.value); + + @override + EzValidator get validator => super.validator.minLength(value); +} + +class ezMaxLength extends ClassPropertyValidator { + final int value; + + const ezMaxLength(this.value); + + @override + EzValidator get validator => super.validator.maxLength(value); +} + +class ezRequired extends ClassPropertyValidator { + final Type? type; + + const ezRequired([this.type]); + + @override + Type get propertyType => type ?? T; +} + +class ezOptional extends ClassPropertyValidator { + final Type type; + final Object? defaultValue; + + const ezOptional(this.type, {this.defaultValue}) + : super(defaultVal: defaultValue); + + @override + Type get propertyType => type; + + @override + bool get optional => true; +} diff --git a/packages/pharaoh/lib/next/core.dart b/packages/pharaoh/lib/next/core.dart new file mode 100644 index 00000000..c01c64c1 --- /dev/null +++ b/packages/pharaoh/lib/next/core.dart @@ -0,0 +1,205 @@ +// ignore_for_file: non_constant_identifier_names + +import 'dart:async'; +import 'dart:io'; + +import 'package:meta/meta.dart'; +import 'package:pharaoh/pharaoh.dart'; +import 'package:spookie/spookie.dart' as spookie; + +import 'http.dart'; +import 'router.dart'; + +import '_core/container.dart'; +import '_core/reflector.dart'; +import '_core/config.dart'; +export '_core/config.dart'; + +export 'package:pharaoh/pharaoh.dart'; + +part '_core/core_impl.dart'; + +typedef RoutesResolver = List Function(); + +/// This should really be a mixin but due to a bug in reflectable.dart#324 +/// TODO:(codekeyz) make this a mixin when reflectable.dart#324 is fixed +abstract class AppInstance { + Application get app => Application.instance; +} + +/// Use this to override the application exceptiosn handler +typedef ApplicationExceptionsHandler = FutureOr Function( + Object exception, + ReqRes reqRes, +); + +abstract interface class Application { + Application(AppConfig config); + + static late final Application instance; + + String get name; + + String get url; + + int get port; + + AppConfig get config; + + T singleton(T instance); + + T instanceOf(); + + void useRoutes(RoutesResolver routeResolver); + + void useViewEngine(ViewEngine viewEngine); +} + +abstract class ApplicationFactory { + final AppConfig appConfig; + + List get providers; + + /// The application's global HTTP middleware stack. + /// + /// These middleware are run during every request to your application. + /// Types here must extends [Middleware]. + List get middlewares; + + /// The application's route middleware groups. + /// + /// Types here must extends [Middleware]. + final Map> middlewareGroups = {}; + + static Map> _middlewareGroups = {}; + + Middleware? _globalMdwCache; + @nonVirtual + Middleware? get globalMiddleware { + if (_globalMdwCache != null) return _globalMdwCache!; + if (middlewares.isEmpty) return null; + return _globalMdwCache = + middlewares.map(_buildHandlerFunc).reduce((val, e) => val.chain(e)); + } + + ApplicationFactory(this.appConfig) { + providers.forEach(ensureIsSubTypeOf); + middlewares.forEach(ensureIsSubTypeOf); + for (final types in middlewareGroups.values) { + types.map(ensureIsSubTypeOf); + } + _middlewareGroups = middlewareGroups; + } + + Future bootstrap({bool listen = true}) async { + await _bootstrapComponents(appConfig); + + if (listen) await startServer(); + } + + Future startServer() async { + final app = Application.instance as _PharaohNextImpl; + + await app + ._createPharaohInstance(onException: onApplicationException) + .listen(port: app.port); + } + + Future _bootstrapComponents(AppConfig config) async { + final spanner = Spanner()..addMiddleware('/', bodyParser); + Application.instance = _PharaohNextImpl(config, spanner); + + final providerInstances = providers.map(createNewInstance); + + /// register dependencies + for (final instance in providerInstances) { + await Future.sync(instance.register); + } + + if (globalMiddleware != null) { + spanner.addMiddleware('/', globalMiddleware!); + } + + /// boot providers + for (final provider in providerInstances) { + await Future.sync(provider.boot); + } + } + + static RequestHandler buildControllerMethod(ControllerMethod method) { + final params = method.params; + + return (req, res) { + final methodName = method.methodName; + final instance = createNewInstance(method.controller); + final mirror = inject.reflect(instance); + + mirror + ..invokeSetter('request', req) + ..invokeSetter('response', res); + + late Function() methodCall; + + if (params.isNotEmpty) { + final args = _resolveControllerMethodArgs(req, method); + methodCall = () => mirror.invoke(methodName, args); + } else { + methodCall = () => mirror.invoke(methodName, []); + } + + return Future.sync(methodCall); + }; + } + + static List _resolveControllerMethodArgs( + Request request, ControllerMethod method) { + if (method.params.isEmpty) return []; + + final args = []; + + for (final param in method.params) { + final meta = param.meta; + if (meta != null) { + args.add(meta.process(request, param)); + continue; + } + } + return args; + } + + static Iterable resolveMiddlewareForGroup(String group) { + final middlewareGroup = ApplicationFactory._middlewareGroups[group]; + if (middlewareGroup == null) { + throw ArgumentError('Middleware group `$group` does not exist'); + } + return middlewareGroup.map(_buildHandlerFunc); + } + + static Middleware _buildHandlerFunc(Type type) { + final instance = createNewInstance(type); + return instance.handler ?? instance.handle; + } + + @visibleForTesting + Future get tester { + final app = (Application.instance as _PharaohNextImpl); + return spookie.request( + app._createPharaohInstance(onException: onApplicationException)); + } + + FutureOr onApplicationException( + PharaohError error, + Request request, + Response response, + ) async { + final exception = error.exception; + if (exception is RequestValidationError) { + return response.json(exception, statusCode: HttpStatus.badRequest); + } else if (error is SpannerRouteValidatorError) { + return response.json({ + 'errors': [exception] + }, statusCode: HttpStatus.badRequest); + } + return response.internalServerError(exception.toString()); + } +} diff --git a/packages/pharaoh/lib/next/http.dart b/packages/pharaoh/lib/next/http.dart new file mode 100644 index 00000000..9f391826 --- /dev/null +++ b/packages/pharaoh/lib/next/http.dart @@ -0,0 +1,69 @@ +library; + +import 'dart:io'; + +import '_core/reflector.dart'; +import 'core.dart'; + +export '_router/meta.dart'; + +@inject +abstract class ClassMiddleware extends AppInstance { + handle(Request req, Response res, NextFunction next) { + next(); + } + + Middleware? get handler => null; +} + +@inject +abstract class ServiceProvider extends AppInstance { + static List get defaultProviders => []; + + void boot() {} + + void register() {} +} + +@inject +abstract class HTTPController extends AppInstance { + late final Request request; + + late final Response response; + + Map get params => request.params; + + Map get queryParams => request.query; + + Map get headers => request.headers; + + Session? get session => request.session; + + get requestBody => request.body; + + bool get expectsJson { + final headerValue = + request.headers[HttpHeaders.acceptEncodingHeader]?.toString(); + return headerValue != null && headerValue.contains('application/json'); + } + + Response badRequest([String? message]) { + const status = 422; + if (message == null) return response.status(status); + return response.json({'error': message}, statusCode: status); + } + + Response notFound([String? message]) { + const status = 404; + if (message == null) return response.status(status); + return response.json({'error': message}, statusCode: status); + } + + Response jsonResponse(data, {int statusCode = 200}) { + return response.json(data, statusCode: statusCode); + } + + Response redirectTo(String url, {int statusCode = 302}) { + return response.redirect(url, statusCode); + } +} diff --git a/packages/pharaoh/lib/next/router.dart b/packages/pharaoh/lib/next/router.dart new file mode 100644 index 00000000..7bdf06ed --- /dev/null +++ b/packages/pharaoh/lib/next/router.dart @@ -0,0 +1,111 @@ +library router; + +import 'package:grammer/grammer.dart'; +import 'package:meta/meta.dart'; + +import '_validation/dto.dart'; +import '_router/meta.dart'; +import '_core/reflector.dart'; +import '_router/utils.dart'; +import 'core.dart'; + +export 'package:spanner/spanner.dart' show HTTPMethod; + +part '_router/definition.dart'; + +abstract interface class Route { + static UseAliasedMiddleware middleware(String name) => + UseAliasedMiddleware(name); + + static ControllerRouteMethodDefinition get( + String path, + ControllerMethodDefinition defn, + ) => + ControllerRouteMethodDefinition( + defn, RouteMapping([HTTPMethod.GET], path)); + + static ControllerRouteMethodDefinition head( + String path, ControllerMethodDefinition defn) => + ControllerRouteMethodDefinition( + defn, RouteMapping([HTTPMethod.HEAD], path)); + + static ControllerRouteMethodDefinition post( + String path, ControllerMethodDefinition defn) => + ControllerRouteMethodDefinition( + defn, RouteMapping([HTTPMethod.POST], path)); + + static ControllerRouteMethodDefinition put( + String path, ControllerMethodDefinition defn) => + ControllerRouteMethodDefinition( + defn, RouteMapping([HTTPMethod.PUT], path)); + + static ControllerRouteMethodDefinition delete( + String path, ControllerMethodDefinition defn) => + ControllerRouteMethodDefinition( + defn, RouteMapping([HTTPMethod.DELETE], path)); + + static ControllerRouteMethodDefinition patch( + String path, ControllerMethodDefinition defn) => + ControllerRouteMethodDefinition( + defn, RouteMapping([HTTPMethod.PATCH], path)); + + static ControllerRouteMethodDefinition options( + String path, ControllerMethodDefinition defn) => + ControllerRouteMethodDefinition( + defn, RouteMapping([HTTPMethod.OPTIONS], path)); + + static ControllerRouteMethodDefinition trace( + String path, ControllerMethodDefinition defn) => + ControllerRouteMethodDefinition( + defn, RouteMapping([HTTPMethod.TRACE], path)); + + static ControllerRouteMethodDefinition mapping( + List methods, + String path, + ControllerMethodDefinition defn, + ) { + var mapping = RouteMapping(methods, path); + if (methods.contains(HTTPMethod.ALL)) { + mapping = RouteMapping([HTTPMethod.ALL], path); + } + return ControllerRouteMethodDefinition(defn, mapping); + } + + static RouteGroupDefinition group(String name, List routes, + {String? prefix}) => + RouteGroupDefinition._(name, definitions: routes, prefix: prefix); + + static RouteGroupDefinition resource(String resource, Type controller, + {String? parameterName}) { + resource = resource.toLowerCase(); + + final resourceId = + '${(parameterName ?? resource).toSingular().toLowerCase()}Id'; + + return Route.group(resource, [ + Route.get('/', (controller, #index)), + Route.get('/<$resourceId>', (controller, #show)), + Route.post('/', (controller, #create)), + Route.put('/<$resourceId>', (controller, #update)), + Route.patch('/<$resourceId>', (controller, #update)), + Route.delete('/<$resourceId>', (controller, #delete)) + ]); + } + + static FunctionalRouteDefinition route( + HTTPMethod method, + String path, + RequestHandler handler, + ) => + FunctionalRouteDefinition.route(method, path, handler); + + static FunctionalRouteDefinition notFound( + RequestHandler handler, [ + HTTPMethod method = HTTPMethod.ALL, + ]) => + Route.route(method, '/*', handler); +} + +Middleware useAliasedMiddleware(String alias) => + ApplicationFactory.resolveMiddlewareForGroup(alias) + .reduce((val, e) => val.chain(e)); diff --git a/packages/pharaoh/lib/next/validation.dart b/packages/pharaoh/lib/next/validation.dart new file mode 100644 index 00000000..685183dc --- /dev/null +++ b/packages/pharaoh/lib/next/validation.dart @@ -0,0 +1,2 @@ +export '_validation/meta.dart'; +export '_validation/dto.dart'; diff --git a/packages/pharaoh/lib/pharaoh.dart b/packages/pharaoh/lib/pharaoh.dart index d69af075..c194417f 100644 --- a/packages/pharaoh/lib/pharaoh.dart +++ b/packages/pharaoh/lib/pharaoh.dart @@ -16,4 +16,4 @@ export 'package:spanner/spanner.dart'; // shelf export 'src/shelf_interop/adapter.dart'; -export 'src/shelf_interop/shelf.dart' show Body; +export 'src/shelf_interop/shelf.dart' show ShelfBody; diff --git a/packages/pharaoh/lib/src/core_impl.dart b/packages/pharaoh/lib/src/core_impl.dart index aad871f4..86a93fae 100644 --- a/packages/pharaoh/lib/src/core_impl.dart +++ b/packages/pharaoh/lib/src/core_impl.dart @@ -151,7 +151,7 @@ class $PharaohImpl extends RouterContract // // TODO(codekeyz): Do this more cleanly when sdk#27886 is fixed. final newStream = chunkedCoding.decoder.bind(res_.body!.read()); - res_.body = shelf.Body(newStream); + res_.body = shelf.ShelfBody(newStream); request.headers.set(HttpHeaders.transferEncodingHeader, 'chunked'); } else if (statusCode >= 200 && statusCode != 204 && diff --git a/packages/pharaoh/lib/src/http/message.dart b/packages/pharaoh/lib/src/http/message.dart index 66c41ad3..0ea7c173 100644 --- a/packages/pharaoh/lib/src/http/message.dart +++ b/packages/pharaoh/lib/src/http/message.dart @@ -44,6 +44,6 @@ abstract class Message { int? get contentLength { final content = body; - return content is Body ? content.contentLength : null; + return content is ShelfBody ? content.contentLength : null; } } diff --git a/packages/pharaoh/lib/src/http/response.dart b/packages/pharaoh/lib/src/http/response.dart index 0e629062..45d8d58e 100644 --- a/packages/pharaoh/lib/src/http/response.dart +++ b/packages/pharaoh/lib/src/http/response.dart @@ -10,7 +10,7 @@ import 'message.dart'; part 'response_impl.dart'; -abstract class Response extends Message { +abstract class Response extends Message { Response(super.body, {super.headers = const {}}); /// Constructs an HTTP Response @@ -21,7 +21,7 @@ abstract class Response extends Message { Map? headers, }) { return ResponseImpl._( - body: body == null ? null : Body(body), + body: body == null ? null : ShelfBody(body), ended: false, statusCode: statusCode, headers: headers ?? {}, diff --git a/packages/pharaoh/lib/src/http/response_impl.dart b/packages/pharaoh/lib/src/http/response_impl.dart index 34e14f27..6c127726 100644 --- a/packages/pharaoh/lib/src/http/response_impl.dart +++ b/packages/pharaoh/lib/src/http/response_impl.dart @@ -40,7 +40,7 @@ class ResponseImpl extends Response { /// /// [statusCode] must be greater than or equal to 100. ResponseImpl._({ - shelf.Body? body, + shelf.ShelfBody? body, int? statusCode, this.ended = false, Map headers = const {}, @@ -76,7 +76,7 @@ class ResponseImpl extends Response { ); @override - Response withBody(Object object) => this..body = shelf.Body(object); + Response withBody(Object object) => this..body = shelf.ShelfBody(object); @override ResponseImpl redirect(String url, [int statusCode = HttpStatus.found]) { @@ -122,7 +122,7 @@ class ResponseImpl extends Response { statusCode = HttpStatus.internalServerError; } - body = shelf.Body(result); + body = shelf.ShelfBody(result); return this.status(statusCode).end(); } @@ -143,13 +143,13 @@ class ResponseImpl extends Response { @override ResponseImpl ok([String? data]) => this.end() ..headers[HttpHeaders.contentTypeHeader] = ContentType.text.toString() - ..body = shelf.Body(data, encoding); + ..body = shelf.ShelfBody(data, encoding); @override ResponseImpl send(Object data) { return this.end() ..headers[HttpHeaders.contentTypeHeader] ??= ContentType.binary.toString() - ..body = shelf.Body(data); + ..body = shelf.ShelfBody(data); } @override diff --git a/packages/pharaoh/lib/src/shelf_interop/shelf.dart b/packages/pharaoh/lib/src/shelf_interop/shelf.dart index 198f9409..90da6379 100644 --- a/packages/pharaoh/lib/src/shelf_interop/shelf.dart +++ b/packages/pharaoh/lib/src/shelf_interop/shelf.dart @@ -1,4 +1,6 @@ -export 'package:shelf/src/body.dart'; +import 'package:shelf/src/body.dart'; export 'package:shelf/src/request.dart'; export 'package:shelf/src/response.dart'; export 'package:shelf/src/middleware.dart'; + +typedef ShelfBody = Body; diff --git a/packages/pharaoh/lib/src/view/view.dart b/packages/pharaoh/lib/src/view/view.dart index 6c71270f..25c9ca13 100644 --- a/packages/pharaoh/lib/src/view/view.dart +++ b/packages/pharaoh/lib/src/view/view.dart @@ -31,7 +31,7 @@ final ReqResHook viewRenderHook = (ReqRes reqRes) async { final result = await Isolate.run( () => viewEngine.render(viewData.name, viewData.data), ); - res = res.end()..body = shelf.Body(result); + res = res.end()..body = shelf.ShelfBody(result); } catch (e) { throw PharaohException.value('Failed to render view ${viewData.name}', e); } diff --git a/packages/pharaoh/pubspec.yaml b/packages/pharaoh/pubspec.yaml index 5b5aefe9..780ec2eb 100644 --- a/packages/pharaoh/pubspec.yaml +++ b/packages/pharaoh/pubspec.yaml @@ -18,6 +18,15 @@ dependencies: # We only need this to implement inter-operability for shelf shelf: ^1.4.1 -dev_dependencies: + # framework + reflectable: ^4.0.5 + get_it: ^7.6.7 + grammer: ^1.0.3 + dotenv: ^4.2.0 + ez_validator: + # ignore: invalid_dependency + git: https://github.com/codekeyz/ez-validator-yaroo.git spookie: + +dev_dependencies: lints: ^3.0.0 diff --git a/packages/pharaoh/test/pharaoh_next/config/config_test.dart b/packages/pharaoh/test/pharaoh_next/config/config_test.dart new file mode 100644 index 00000000..b18af6f6 --- /dev/null +++ b/packages/pharaoh/test/pharaoh_next/config/config_test.dart @@ -0,0 +1,37 @@ +import 'package:pharaoh/next/core.dart'; +import 'package:pharaoh/next/http.dart'; +import 'package:spookie/spookie.dart'; + +import '../core/core_test.dart'; +import './config_test.reflectable.dart' as r; + +Matcher throwsArgumentErrorWithMessage(String message) => + throwsA(isA().having((p0) => p0.message, '', message)); + +class AppServiceProvider extends ServiceProvider {} + +void main() { + setUpAll(() => r.initializeReflectable()); + + group('App Config Test', () { + test('should return AppConfig instance', () async { + final testApp = TestKidsApp( + middlewares: [TestMiddleware], providers: [AppServiceProvider]); + expect(testApp, isNotNull); + }); + + test('should use prioritize `port` over port in `url`', () { + const config = AppConfig( + name: 'Foo Bar', + environment: 'debug', + isDebug: true, + key: 'asdfajkl', + url: 'http://localhost:3000', + port: 4000, + ); + + expect(config.url, 'http://localhost:4000'); + expect(config.port, 4000); + }); + }); +} diff --git a/packages/pharaoh/test/pharaoh_next/core/application_factory_test.dart b/packages/pharaoh/test/pharaoh_next/core/application_factory_test.dart new file mode 100644 index 00000000..62c4816f --- /dev/null +++ b/packages/pharaoh/test/pharaoh_next/core/application_factory_test.dart @@ -0,0 +1,56 @@ +import 'package:pharaoh/next/core.dart'; +import 'package:pharaoh/next/http.dart'; +import 'package:pharaoh/next/router.dart'; + +import 'package:spookie/spookie.dart'; + +import 'application_factory_test.reflectable.dart'; + +class TestHttpController extends HTTPController { + Future index() async { + return response.ok('Hello World'); + } + + Future show(@query int userId) async { + return response.ok('User $userId'); + } +} + +void main() { + initializeReflectable(); + + group('ApplicationFactory', () { + group('.buildControllerMethod', () { + group('should return request handler', () { + test('for method with no args', () async { + final indexMethod = ControllerMethod((TestHttpController, #index)); + final handler = ApplicationFactory.buildControllerMethod(indexMethod); + + expect(handler, isA()); + + await (await request(Pharaoh()..get('/', handler))) + .get('/') + .expectStatus(200) + .expectBody('Hello World') + .test(); + }); + + test('for method with args', () async { + final showMethod = ControllerMethod( + (TestHttpController, #show), + [ControllerMethodParam('userId', int, meta: query)], + ); + + final handler = ApplicationFactory.buildControllerMethod(showMethod); + expect(handler, isA()); + + await (await request(Pharaoh()..get('/test', handler))) + .get('/test?userId=2345') + .expectStatus(200) + .expectBody('User 2345') + .test(); + }); + }); + }); + }); +} diff --git a/packages/pharaoh/test/pharaoh_next/core/core_test.dart b/packages/pharaoh/test/pharaoh_next/core/core_test.dart new file mode 100644 index 00000000..75543e41 --- /dev/null +++ b/packages/pharaoh/test/pharaoh_next/core/core_test.dart @@ -0,0 +1,113 @@ +import 'package:pharaoh/next/core.dart'; +import 'package:pharaoh/next/http.dart'; +import 'package:pharaoh/next/router.dart'; +import 'package:spookie/spookie.dart'; + +import '../config/config_test.dart'; +import 'core_test.reflectable.dart'; + +const appConfig = AppConfig( + name: 'Test App', + environment: 'production', + isDebug: false, + url: 'http://localhost', + port: 3000, + key: 'askdfjal;ksdjkajl;j', +); + +class TestMiddleware extends ClassMiddleware {} + +class FoobarMiddleware extends ClassMiddleware { + @override + Middleware get handler => (req, res, next) => next(); +} + +class TestKidsApp extends ApplicationFactory { + final AppConfig? config; + + TestKidsApp({ + this.providers = const [], + this.middlewares = const [], + this.config, + }) : super(config ?? appConfig); + + @override + final List providers; + + @override + final List middlewares; + + @override + Map> get middlewareGroups => { + 'api': [FoobarMiddleware], + 'web': [String] + }; +} + +void main() { + initializeReflectable(); + + group('Core', () { + final testApp = TestKidsApp(middlewares: [TestMiddleware]); + + group('should error', () { + test('when invalid provider type passed', () { + expect( + () => + TestKidsApp(middlewares: [TestMiddleware], providers: [String]), + throwsArgumentErrorWithMessage( + 'Ensure your class extends `ServiceProvider` class')); + }); + + test('when invalid middleware type passed middlewares is not valid', () { + expect( + () => TestKidsApp( + middlewares: [String], providers: [AppServiceProvider]), + throwsArgumentErrorWithMessage( + 'Ensure your class extends `ClassMiddleware` class')); + }); + }); + + test('should resolve global middleware', () { + expect(testApp.globalMiddleware, isA()); + }); + + group('when middleware group', () { + test('should resolve', () { + final group = Route.middleware('api').group('Users', [ + Route.route(HTTPMethod.GET, '/', (req, res) => null), + ]); + + expect(group.paths, ['[ALL]: /users', '[GET]: /users/']); + }); + + test('should error when not exist', () { + expect( + () => Route.middleware('foo').group('Users', [ + Route.route(HTTPMethod.GET, '/', (req, res) => null), + ]), + throwsA( + isA().having((p0) => p0.message, 'message', + 'Middleware group `foo` does not exist'), + ), + ); + }); + }); + + test('should throw if type is not subtype of Middleware', () { + final middlewares = ApplicationFactory.resolveMiddlewareForGroup('api'); + expect(middlewares, isA>()); + + expect(middlewares.length, 1); + + expect(() => ApplicationFactory.resolveMiddlewareForGroup('web'), + throwsA(isA())); + }); + + test('should return tester', () async { + await testApp.bootstrap(listen: false); + + expect(await testApp.tester, isA()); + }); + }); +} diff --git a/packages/pharaoh/test/pharaoh_next/http/meta_test.dart b/packages/pharaoh/test/pharaoh_next/http/meta_test.dart new file mode 100644 index 00000000..984424f7 --- /dev/null +++ b/packages/pharaoh/test/pharaoh_next/http/meta_test.dart @@ -0,0 +1,293 @@ +import 'dart:io'; + +import 'package:pharaoh/next/core.dart'; +import 'package:pharaoh/next/http.dart'; +import 'package:pharaoh/next/router.dart'; +import 'package:pharaoh/next/validation.dart'; + +import 'package:spookie/spookie.dart'; + +import 'meta_test.reflectable.dart'; + +class TestDTO extends BaseDTO { + String get username; + + String get lastname; + + int get age; +} + +Pharaoh get pharaohWithErrorHdler => Pharaoh() + ..onError((error, req, res) { + final exception = error.exception; + if (exception is RequestValidationError) { + return res.json(exception.errorBody, statusCode: 422); + } + + return res.internalServerError(error.toString()); + }); + +void main() { + initializeReflectable(); + + group('Meta', () { + group('Param', () { + test('should use name set in meta', () async { + final app = pharaohWithErrorHdler + ..get('//hello', (req, res) { + final actualParam = Param('userId'); + const ctrlMethodParam = ControllerMethodParam('user', String); + + return res.ok(actualParam.process(req, ctrlMethodParam)); + }); + + await (await request(app)) + .get('/234/hello') + .expectStatus(200) + .expectBody('234') + .test(); + }); + + test( + 'should use controller method property name if meta name not provided', + () async { + final app = pharaohWithErrorHdler + ..get('/boys/', (req, res) { + const ctrlMethodParam = ControllerMethodParam('user', String); + + final result = param.process(req, ctrlMethodParam); + return res.ok(result); + }); + + await (await request(app)) + .get('/boys/499') + .expectStatus(200) + .expectBody('499') + .test(); + }); + + test('when param value not valid', () async { + final app = pharaohWithErrorHdler + ..get('/test/', (req, res) { + const ctrlMethodParam = ControllerMethodParam('userId', int); + + final result = Param().process(req, ctrlMethodParam); + return res.ok(result.toString()); + }); + + await (await request(app)) + .get('/test/asfkd') + .expectStatus(422) + .expectJsonBody({ + 'location': 'param', + 'errors': ['userId must be a int type'] + }).test(); + + await (await request(app)) + .get('/test/2345') + .expectStatus(200) + .expectBody('2345') + .test(); + }); + }); + + group('Query', () { + test('should use name set in query', () async { + final app = pharaohWithErrorHdler + ..get('/foo', (req, res) { + final actualParam = Query('userId'); + const ctrlMethodParam = ControllerMethodParam('user', String); + + final result = actualParam.process(req, ctrlMethodParam); + return res.ok(result); + }); + + await (await request(app)) + .get('/foo?userId=Chima') + .expectStatus(200) + .expectBody('Chima') + .test(); + }); + + test( + 'should use controller method property name if Query name not provided', + () async { + final app = pharaohWithErrorHdler + ..get('/bar', (req, res) { + const ctrlMethodParam = ControllerMethodParam('userId', String); + + final result = query.process(req, ctrlMethodParam); + return res.ok(result); + }); + + await (await request(app)) + .get('/bar?userId=Precious') + .expectStatus(200) + .expectBody('Precious') + .test(); + }); + + test('when Query value not valid', () async { + final app = pharaohWithErrorHdler + ..get('/moo', (req, res) { + const ctrlMethodParam = ControllerMethodParam('name', int); + + final result = query.process(req, ctrlMethodParam); + return res.ok(result.toString()); + }); + + await (await request(app)) + .get('/moo?name=Chima') + .expectStatus(422) + .expectJsonBody({ + 'location': 'query', + 'errors': ['name must be a int type'] + }).test(); + + await (await request(app)) + .get('/moo') + .expectStatus(422) + .expectBody('{"location":"query","errors":["name is required"]}') + .test(); + + await (await request(app)) + .get('/moo?name=244') + .expectStatus(200) + .expectBody('244') + .test(); + }); + }); + + group('Header', () { + test('should use name set in meta', () async { + final app = pharaohWithErrorHdler + ..get('/foo', (req, res) { + final actualParam = Header(HttpHeaders.authorizationHeader); + const ctrlMethodParam = ControllerMethodParam('token', String); + + final result = actualParam.process(req, ctrlMethodParam); + return res.json(result); + }); + + await (await request(app)) + .get('/foo', headers: { + HttpHeaders.authorizationHeader: 'foo token', + }) + .expectStatus(200) + .expectJsonBody('[foo token]') + .test(); + }); + + test( + 'should use controller method property name if meta name not provided', + () async { + final app = pharaohWithErrorHdler + ..get('/bar', (req, res) { + final result = + header.process(req, ControllerMethodParam('token', String)); + return res.ok(result); + }); + + await (await request(app)) + .get('/bar', headers: {'token': 'Hello Token'}) + .expectStatus(200) + .expectBody('[Hello Token]') + .test(); + }); + + test('when Header value not valid', () async { + final app = pharaohWithErrorHdler + ..get('/moo', (req, res) { + final result = + header.process(req, ControllerMethodParam('age_max', String)); + return res.ok(result.toString()); + }); + + await (await request(app)) + .get('/moo', headers: {'age_max': 'Chima'}) + .expectStatus(200) + .expectBody('[Chima]') + .test(); + + await (await request(app)) + .get('/moo') + .expectStatus(422) + .expectBody( + '{"location":"header","errors":["age_max is required"]}') + .test(); + }); + }); + + group('Body', () { + test('should use name set in meta', () async { + final app = pharaohWithErrorHdler + ..post('/hello', (req, res) { + final actualParam = Body(); + final result = actualParam.process( + req, ControllerMethodParam('reqBody', dynamic)); + return res.json(result); + }); + await (await request(app)) + .post('/hello', {'foo': "bar"}) + .expectStatus(200) + .expectJsonBody({'foo': 'bar'}) + .test(); + }); + + test('when body not provided', () async { + final app = pharaohWithErrorHdler + ..post('/test', (req, res) { + final result = + body.process(req, ControllerMethodParam('reqBody', dynamic)); + return res.ok(result.toString()); + }); + + await (await request(app)) + .post('/test', null) + .expectStatus(422) + .expectJsonBody({ + 'location': 'body', + 'errors': ['body is required'] + }).test(); + + await (await request(app)) + .post('/test', {'hello': 'Foo'}) + .expectStatus(200) + .expectBody('{hello: Foo}') + .test(); + }); + + test('when dto provided', () async { + final dto = TestDTO(); + final testData = {'username': 'Foo', 'lastname': 'Bar', 'age': 22}; + + final app = pharaohWithErrorHdler + ..post('/mongo', (req, res) { + final actualParam = Body(); + final result = actualParam.process( + req, ControllerMethodParam('reqBody', TestDTO, dto: dto)); + return res + .json({'username': result is TestDTO ? result.username : null}); + }); + + await (await request(app)) + .post('/mongo', {}) + .expectStatus(422) + .expectJsonBody({ + 'location': 'body', + 'errors': [ + 'username: The field is required', + 'lastname: The field is required', + 'age: The field is required' + ] + }) + .test(); + + await (await request(app)) + .post('/mongo', testData) + .expectStatus(200) + .expectJsonBody({'username': 'Foo'}).test(); + }); + }); + }); +} diff --git a/packages/pharaoh/test/pharaoh_next/router_test.dart b/packages/pharaoh/test/pharaoh_next/router_test.dart new file mode 100644 index 00000000..ba575fa3 --- /dev/null +++ b/packages/pharaoh/test/pharaoh_next/router_test.dart @@ -0,0 +1,206 @@ +import 'package:pharaoh/next/http.dart'; +import 'package:pharaoh/next/router.dart'; +import 'package:spookie/spookie.dart'; + +import './router_test.reflectable.dart'; +import 'core/core_test.dart'; + +class TestController extends HTTPController { + void create() {} + + void index() {} + + void show() {} + + void update() {} + + void delete() {} +} + +void main() { + setUpAll(() => initializeReflectable()); + + group('Router', () { + group('when route group', () { + test('with routes', () { + final group = Route.group('merchants', [ + Route.get('/get', (TestController, #index)), + Route.delete('/delete', (TestController, #delete)), + Route.put('/update', (TestController, #update)), + ]); + + expect(group.paths, [ + '[GET]: /merchants/get', + '[DELETE]: /merchants/delete', + '[PUT]: /merchants/update', + ]); + }); + + test('with prefix', () { + final group = Route.group( + 'Merchants', + [ + Route.get('/foo', (TestController, #index)), + Route.delete('/bar', (TestController, #delete)), + Route.put('/moo', (TestController, #update)), + ], + prefix: 'foo', + ); + + expect(group.paths, [ + '[GET]: /foo/foo', + '[DELETE]: /foo/bar', + '[PUT]: /foo/moo', + ]); + }); + + test('with handler', () { + final group = Route.group('users', [ + Route.route(HTTPMethod.GET, '/my-name', (req, res) => null), + ]); + expect(group.paths, ['[GET]: /users/my-name']); + }); + + test('with sub groups', () { + final group = Route.group('users', [ + Route.get('/get', (TestController, #index)), + Route.delete('/delete', (TestController, #delete)), + Route.put('/update', (TestController, #update)), + // + Route.group('customers', [ + Route.get('/foo', (TestController, #index)), + Route.delete('/bar', (TestController, #delete)), + Route.put('/set', (TestController, #update)), + ]), + ]); + + expect(group.paths, [ + '[GET]: /users/get', + '[DELETE]: /users/delete', + '[PUT]: /users/update', + '[GET]: /users/customers/foo', + '[DELETE]: /users/customers/bar', + '[PUT]: /users/customers/set', + ]); + }); + + group('when middlewares used', () { + test('should add to routes', () { + final group = Route.group('users', [ + Route.get('/get', (TestController, #index)), + Route.delete('/delete', (TestController, #delete)), + Route.put('/update', (TestController, #update)), + // + Route.group('customers', [ + Route.get('/foo', (TestController, #index)), + Route.delete('/bar', (TestController, #delete)), + Route.put('/set', (TestController, #update)), + ]), + ]); + + expect(group.paths, [ + '[GET]: /users/get', + '[DELETE]: /users/delete', + '[PUT]: /users/update', + '[GET]: /users/customers/foo', + '[DELETE]: /users/customers/bar', + '[PUT]: /users/customers/set', + ]); + }); + + test('should handle nested groups', () { + final group = Route.group('users', [ + Route.get('/get', (TestController, #index)), + Route.delete('/delete', (TestController, #delete)), + Route.put('/update', (TestController, #update)), + // + Route.group('customers', [ + Route.get('/foo', (TestController, #index)), + Route.delete('/bar', (TestController, #delete)), + Route.put('/set', (TestController, #update)), + ]), + ]); + + expect(group.paths, [ + '[GET]: /users/get', + '[DELETE]: /users/delete', + '[PUT]: /users/update', + '[GET]: /users/customers/foo', + '[DELETE]: /users/customers/bar', + '[PUT]: /users/customers/set', + ]); + }); + }); + + test('when handle route resource', () { + final group = + Route.group('foo', [Route.resource('bar', TestController)]) + ..middleware([ + (req, res, next) => next(), + (req, res, next) => next(), + ]); + + expect(group.paths, [ + '[ALL]: /foo', + '[GET]: /foo/bar/', + '[GET]: /foo/bar/', + '[POST]: /foo/bar/', + '[PUT]: /foo/bar/', + '[PATCH]: /foo/bar/', + '[DELETE]: /foo/bar/' + ]); + }); + + test('when handle route resource', () { + final group = Route.group('foo', [ + Route.resource('bar', TestController), + ]); + + expect(group.paths, [ + '[GET]: /foo/bar/', + '[GET]: /foo/bar/', + '[POST]: /foo/bar/', + '[PUT]: /foo/bar/', + '[PATCH]: /foo/bar/', + '[DELETE]: /foo/bar/' + ]); + }); + + test('when used with middleware', () { + TestKidsApp(); + + final group = Route.middleware('api').group('merchants', [ + Route.route(HTTPMethod.GET, '/create', (req, res) => null), + Route.group('users', [ + Route.get('/get', (TestController, #index)), + Route.delete('/delete', (TestController, #delete)), + Route.put('/update', (TestController, #update)), + Route.middleware('api').group('hello', [ + Route.get('/world', (TestController, #index)), + ]) + ]), + ]); + + expect(group.paths, [ + '[ALL]: /merchants', + '[GET]: /merchants/create', + '[GET]: /merchants/users/get', + '[DELETE]: /merchants/users/delete', + '[PUT]: /merchants/users/update', + '[ALL]: /merchants/users/hello', + '[GET]: /merchants/users/hello/world' + ]); + }); + }); + + test('should error when controller method not found', () { + expect( + () => Route.group( + 'Merchants', [Route.get('/foo', (TestController, #foobar))], + prefix: 'foo'), + throwsA(isA().having((p0) => p0.message, '', + 'TestController does not have method #foobar')), + ); + }); + }); +} diff --git a/packages/pharaoh/test/pharaoh_next/validation/validation_test.dart b/packages/pharaoh/test/pharaoh_next/validation/validation_test.dart new file mode 100644 index 00000000..0799d79a --- /dev/null +++ b/packages/pharaoh/test/pharaoh_next/validation/validation_test.dart @@ -0,0 +1,216 @@ +import 'package:pharaoh/next/http.dart'; +import 'package:pharaoh/next/validation.dart'; +import 'package:pharaoh/pharaoh.dart'; +import 'package:spookie/spookie.dart'; + +import 'validation_test.reflectable.dart'; + +class TestDTO extends BaseDTO { + String get username; + + String get lastname; + + int get age; +} + +class TestSingleOptional extends BaseDTO { + String get nationality; + + @ezOptional(String) + String? get address; + + @ezOptional(String, defaultValue: 'Ghana') + String get country; +} + +class DTOTypeMismatch extends BaseDTO { + @ezOptional(int) + String? get name; +} + +void main() { + initializeReflectable(); + + group('Validation', () { + group('when `ezRequired`', () { + test('when passed type as argument', () { + final requiredValidator = ezRequired(String).validator.build(); + expect(requiredValidator(null), 'The field is required'); + expect(requiredValidator(24), 'The field must be a String type'); + expect(requiredValidator('Foo'), isNull); + }); + + test('when passed type through generics', () { + final requiredValidator = ezRequired().validator.build(); + expect(requiredValidator(null), 'The field is required'); + expect(requiredValidator('Hello'), 'The field must be a int type'); + expect(requiredValidator(24), isNull); + }); + + test('when mis-matched types', () { + final requiredValidator = ezRequired().validator.build(); + expect(requiredValidator(null), 'The field is required'); + expect(requiredValidator('Hello'), 'The field must be a int type'); + expect(requiredValidator(24), isNull); + }); + }); + + test('when `ezOptional`', () { + final optionalValidator = ezOptional(String).validator.build(); + expect(optionalValidator(null), isNull); + expect(optionalValidator(24), 'The field must be a String type'); + expect(optionalValidator('Foo'), isNull); + }); + + test('when `ezEmail`', () { + final emailValidator = ezEmail().validator.build(); + expect(emailValidator('foo'), 'The field is not a valid email address'); + expect(emailValidator(24), 'The field must be a String type'); + expect(emailValidator('chima@yaroo.dev'), isNull); + }); + + test('when `ezMinLength`', () { + final val1 = ezMinLength(4).validator.build(); + expect(val1('foo'), 'The field must be at least 4 characters long'); + expect(val1('foob'), isNull); + expect(val1('foobd'), isNull); + }); + + test('when `ezMaxLength`', () { + final val1 = ezMaxLength(10).validator.build(); + expect(val1('foobasdfkasdfasdf'), + 'The field must be at most 10 characters long'); + expect(val1('foobasdfk'), isNull); + }); + + test('when `ezDateTime`', () { + var requiredValidator = ezDateTime().validator.build(); + final now = DateTime.now(); + expect(requiredValidator('foo'), 'The field must be a DateTime type'); + expect(requiredValidator(now), isNull); + expect(requiredValidator(null), 'The field is required'); + + requiredValidator = ezDateTime(optional: true).validator.build(); + expect(requiredValidator(null), isNull); + expect(requiredValidator('df'), 'The field must be a DateTime type'); + }); + }); + + group('when used in a class', () { + final pharaoh = Pharaoh() + ..onError((error, req, res) { + final actualError = error.exception; + if (actualError is RequestValidationError) { + return res.json(actualError.errorBody, statusCode: 422); + } + + return res.internalServerError(actualError.toString()); + }); + late Spookie appTester; + + setUpAll(() async => appTester = await request(pharaoh)); + + test('when no metas', () async { + final dto = TestDTO(); + final testData = {'username': 'Foo', 'lastname': 'Bar', 'age': 22}; + + final app = pharaoh + ..post('/', (req, res) { + dto.make(req); + return res.json({ + 'firstname': dto.username, + 'lastname': dto.lastname, + 'age': dto.age + }); + }); + + await appTester + .post('/', {}) + .expectStatus(422) + .expectJsonBody({ + 'location': 'body', + 'errors': [ + 'username: The field is required', + 'lastname: The field is required', + 'age: The field is required' + ] + }) + .test(); + + await (await request(app)) + .post('/', testData) + .expectStatus(200) + .expectJsonBody( + {'firstname': 'Foo', 'lastname': 'Bar', 'age': 22}).test(); + }); + + test('when single property optional', () async { + final dto = TestSingleOptional(); + + final app = pharaoh + ..post('/optional', (req, res) { + dto.make(req); + + return res.json({ + 'nationality': dto.nationality, + 'address': dto.address, + 'country': dto.country + }); + }); + + await (await request(app)) + .post('/optional', {}) + .expectStatus(422) + .expectJsonBody({ + 'location': 'body', + 'errors': ['nationality: The field is required'] + }) + .test(); + + await (await request(app)) + .post('/optional', {'nationality': 'Ghanaian'}) + .expectStatus(200) + .expectJsonBody( + {'nationality': 'Ghanaian', 'address': null, 'country': 'Ghana'}) + .test(); + + await (await request(app)) + .post('/optional', {'nationality': 'Ghanaian', 'address': 344}) + .expectStatus(422) + .expectJsonBody({ + 'location': 'body', + 'errors': ['address: The field must be a String type'] + }) + .test(); + + await (await request(app)) + .post('/optional', + {'nationality': 'Ghanaian', 'address': 'Terminalia Street'}) + .expectStatus(200) + .expectJsonBody({ + 'nationality': 'Ghanaian', + 'address': 'Terminalia Street', + 'country': 'Ghana' + }) + .test(); + }); + + test('when type mismatch', () async { + final dto = DTOTypeMismatch(); + + pharaoh.post('/type-mismatch', (req, res) { + dto.make(req); + return res.ok('Foo Bar'); + }); + + await (await request(pharaoh)) + .post('/type-mismatch', {'name': 'Chima'}) + .expectStatus(500) + .expectJsonBody({ + 'error': + 'Invalid argument(s): Type Mismatch between ezOptional(int) & DTOTypeMismatch class property name->(String)' + }) + .test(); + }); + }); +}