From b535e5e70d883f3220754b670a7ab39f734220d8 Mon Sep 17 00:00:00 2001 From: Javad Zobeidi <45537050+javad-zobeidi@users.noreply.github.com> Date: Wed, 24 Sep 2025 11:03:29 +0330 Subject: [PATCH 1/7] feat: add database health check for non-pooled connections --- lib/src/database/_connection_manager.dart | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/lib/src/database/_connection_manager.dart b/lib/src/database/_connection_manager.dart index 2ceb373..621adba 100644 --- a/lib/src/database/_connection_manager.dart +++ b/lib/src/database/_connection_manager.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:vania/src/exception/database_exception.dart'; import '../contract/database/_connectors/_database_connection.dart'; @@ -25,8 +27,10 @@ class ConnectionManager { bool get isConnected => connectionMap.isNotEmpty; DatabaseConnection? connection([String? connectionName]) { - final name = connectionName ?? defaultConnection; - return connectionMap[name]; + if (connectionName == null || connectionName.isEmpty) { + connectionName = defaultConnection; + } + return connectionMap[connectionName]; } Future connect(DBConfig config, String connectionName) async { @@ -40,6 +44,10 @@ class ConnectionManager { _monitor, ); connectionMap[connectionName] = monitoredConnection; + + if (!config.pool) { + await _checkDatabaseHealth(monitoredConnection); + } } on InvalidArgumentException catch (e) { Logger.log(e.message, type: Logger.ERROR); throw DatabaseException( @@ -49,6 +57,14 @@ class ConnectionManager { } } + Future _checkDatabaseHealth(DatabaseConnection connection) async { + Timer.periodic(Duration(minutes: 5), (timer) async { + try { + await connection.execute('SELECT 1;'); + } catch (_) {} + }); + } + Future transaction( Future Function() action, [ String? connectionName, From 6a549a6d1693a590696e4ebcba10905625175c6b Mon Sep 17 00:00:00 2001 From: Javad Zobeidi <45537050+javad-zobeidi@users.noreply.github.com> Date: Thu, 2 Oct 2025 00:12:18 +0330 Subject: [PATCH 2/7] feat(form-request): add FormRequest support for request validation --- .../http/request/form_validation.dart | 8 + lib/src/exception/unauthorized_exception.dart | 5 + lib/src/http/validation/field_validation.dart | 307 ++++++++++++++++++ test/src/extensions/map_extension_test.dart | 56 ---- 4 files changed, 320 insertions(+), 56 deletions(-) create mode 100644 lib/src/contract/http/request/form_validation.dart create mode 100644 lib/src/exception/unauthorized_exception.dart create mode 100644 lib/src/http/validation/field_validation.dart delete mode 100644 test/src/extensions/map_extension_test.dart diff --git a/lib/src/contract/http/request/form_validation.dart b/lib/src/contract/http/request/form_validation.dart new file mode 100644 index 0000000..26c5e04 --- /dev/null +++ b/lib/src/contract/http/request/form_validation.dart @@ -0,0 +1,8 @@ +import '../../../http/validation/custom_validation_rule.dart'; + +abstract class FormValidation { + dynamic rules(); + List customRule() => []; + Map messages() => {}; + bool authorize() => true; +} diff --git a/lib/src/exception/unauthorized_exception.dart b/lib/src/exception/unauthorized_exception.dart new file mode 100644 index 0000000..caa2fad --- /dev/null +++ b/lib/src/exception/unauthorized_exception.dart @@ -0,0 +1,5 @@ +import 'base_http_exception.dart'; + +class UnauthorizedException extends BaseHttpResponseException { + UnauthorizedException({required super.message, required super.code}); +} diff --git a/lib/src/http/validation/field_validation.dart b/lib/src/http/validation/field_validation.dart new file mode 100644 index 0000000..9686f7a --- /dev/null +++ b/lib/src/http/validation/field_validation.dart @@ -0,0 +1,307 @@ +class FieldValidation { + final String fieldName; + final List _rules = []; + final Map _messages = {}; + + FieldValidation(this.fieldName); + + Map get toMapMessages => _messages.map((key, message) { + final parts = key.split('.*.'); + final ruleName = parts.isNotEmpty ? parts.last : key; + return MapEntry(ruleName, message); + }); + + FieldValidation alpha({String? messages}) { + _rules.add('alpha'); + if (messages != null) { + _messages['$fieldName.alpha'] = messages; + } + return this; + } + + FieldValidation alphaDash({String? messages}) { + _rules.add('alpha_dash'); + if (messages != null) { + _messages['$fieldName.alpha_dash'] = messages; + } + return this; + } + + FieldValidation alphaNumeric({String? messages}) { + _rules.add('alpha_numeric'); + if (messages != null) { + _messages['$fieldName.alpha_numeric'] = messages; + } + return this; + } + + FieldValidation between(int first, int second, {String? messages}) { + _rules.add('between:$first,$second'); + if (messages != null) { + _messages['$fieldName.between'] = messages; + } + return this; + } + + FieldValidation boolean({String? messages}) { + _rules.add('boolean'); + if (messages != null) { + _messages['$fieldName.boolean'] = messages; + } + return this; + } + + FieldValidation confirmed({String? messages}) { + _rules.add('confirmed'); + if (messages != null) { + _messages['$fieldName.confirmed'] = messages; + } + return this; + } + + FieldValidation date({String? messages}) { + _rules.add('date'); + if (messages != null) { + _messages['$fieldName.date'] = messages; + } + return this; + } + + FieldValidation dateTime({String? messages}) { + _rules.add('date_time'); + if (messages != null) { + _messages['$fieldName.date_time'] = messages; + } + return this; + } + + FieldValidation email({String? messages}) { + _rules.add('email'); + if (messages != null) { + _messages['$fieldName.email'] = messages; + } + return this; + } + + FieldValidation endWith(String value, {String? messages}) { + _rules.add('end_with:$value'); + if (messages != null) { + _messages['$fieldName.end_with'] = messages; + } + return this; + } + + FieldValidation file(List types, {String? messages}) { + final formatted = 'file:${types.join(',')}'; + _rules.add(formatted); + if (messages != null) { + _messages['$fieldName.file'] = messages; + } + return this; + } + + FieldValidation greaterThan(int value, {String? messages}) { + _rules.add('greater_than:$value'); + if (messages != null) { + _messages['$fieldName.greater_than'] = messages; + } + return this; + } + + FieldValidation image({String? messages}) { + _rules.add('image'); + if (messages != null) { + _messages['$fieldName.image'] = messages; + } + return this; + } + + FieldValidation integer({String? messages}) { + _rules.add('integer'); + if (messages != null) { + _messages['$fieldName.integer'] = messages; + } + return this; + } + + FieldValidation ip({String? messages}) { + _rules.add('ip'); + if (messages != null) { + _messages['$fieldName.ip'] = messages; + } + return this; + } + + FieldValidation isDouble({String? messages}) { + _rules.add('double'); + if (messages != null) { + _messages['$fieldName.double'] = messages; + } + return this; + } + + FieldValidation isIn(List value, {String? messages}) { + _rules.add('in:${value.join(',')}'); + if (messages != null) { + _messages['$fieldName.in'] = messages; + } + return this; + } + + FieldValidation isList({String? messages}) { + _rules.add('array'); + if (messages != null) { + _messages['$fieldName.array'] = messages; + } + return this; + } + + FieldValidation json({String? messages}) { + _rules.add('json'); + if (messages != null) { + _messages['$fieldName.json'] = messages; + } + return this; + } + + FieldValidation lengthBetween(int first, int second, {String? messages}) { + _rules.add('length_between:$first,$second'); + if (messages != null) { + _messages['$fieldName.length_between'] = messages; + } + return this; + } + + FieldValidation lessThan(int value, {String? messages}) { + _rules.add('less_than:$value'); + if (messages != null) { + _messages['$fieldName.less_than'] = messages; + } + return this; + } + + FieldValidation max(int value, {String? messages}) { + _rules.add('max:$value'); + if (messages != null) { + _messages['$fieldName.max'] = messages; + } + return this; + } + + FieldValidation maxLength(int value, {String? messages}) { + _rules.add('max_length:$value'); + if (messages != null) { + _messages['$fieldName.max_length'] = messages; + } + return this; + } + + FieldValidation min(int value, {String? messages}) { + _rules.add('min:$value'); + if (messages != null) { + _messages['$fieldName.min'] = messages; + } + return this; + } + + FieldValidation minLength(int value, {String? messages}) { + _rules.add('min_length:$value'); + if (messages != null) { + _messages['$fieldName.min_length'] = messages; + } + return this; + } + + FieldValidation notIn(List value, {String? messages}) { + _rules.add('not_in:${value.join(',')}'); + if (messages != null) { + _messages['$fieldName.not_in'] = messages; + } + return this; + } + + FieldValidation numeric({String? messages}) { + _rules.add('numeric'); + if (messages != null) { + _messages['$fieldName.numeric'] = messages; + } + return this; + } + + FieldValidation regExp({String? messages}) { + _rules.add('reg_exp'); + if (messages != null) { + _messages['$fieldName.reg_exp'] = messages; + } + return this; + } + + FieldValidation required({String? messages}) { + _rules.add('required'); + if (messages != null) { + _messages['$fieldName.required'] = messages; + } + return this; + } + + FieldValidation requiredIf(List value, {String? messages}) { + _rules.add('required_if:${value.join(',')}'); + if (messages != null) { + _messages['$fieldName.required_if'] = messages; + } + return this; + } + + FieldValidation requiredIfNot(List value, {String? messages}) { + _rules.add('required_if_not:${value.join(',')}'); + if (messages != null) { + _messages['$fieldName.required_if_not'] = messages; + } + return this; + } + + FieldValidation startWith(String value, {String? messages}) { + _rules.add('start_with:$value'); + if (messages != null) { + _messages['$fieldName.start_with'] = messages; + } + return this; + } + + FieldValidation string({String? messages}) { + _rules.add('string'); + if (messages != null) { + _messages['$fieldName.string'] = messages; + } + return this; + } + + @override + String toString() { + return _rules.join('|'); + } + + FieldValidation unique(String table, String column, {String? messages}) { + _rules.add('unique:$table,$column'); + if (messages != null) { + _messages['$fieldName.unique'] = messages; + } + return this; + } + + FieldValidation url({String? messages}) { + _rules.add('url'); + if (messages != null) { + _messages['$fieldName.url'] = messages; + } + return this; + } + + FieldValidation uuid({String? messages}) { + _rules.add('uuid'); + if (messages != null) { + _messages['$fieldName.uuid'] = messages; + } + return this; + } +} diff --git a/test/src/extensions/map_extension_test.dart b/test/src/extensions/map_extension_test.dart deleted file mode 100644 index cd033e6..0000000 --- a/test/src/extensions/map_extension_test.dart +++ /dev/null @@ -1,56 +0,0 @@ -import 'package:test/test.dart'; -import 'package:vania/src/extensions/map_extension.dart'; - -void main() { - group('MapExtensions Tests', () { - test('getParam retrieves a value correctly', () { - Map map = { - 'user': { - 'name': {'first': 'John', 'last': 'Doe'} - } - }; - expect(map.getParam('user.name.first'), equals('John')); - }); - - test('getParam returns null for non-existent key', () { - Map map = { - 'user': { - 'name': {'first': 'John', 'last': 'Doe'} - } - }; - expect(map.getParam('user.name.middle'), isNull); - }); - - test('removeParam removes the key and returns map', () { - Map map = { - 'user': { - 'name': {'first': 'John', 'last': 'Doe'} - } - }; - map.removeParam('user.name.first'); - expect( - map, - equals({ - 'user': { - 'name': {'last': 'Doe'} - } - })); - }); - - test('removeParam does nothing on incorrect path', () { - Map map = { - 'user': { - 'name': {'first': 'John', 'last': 'Doe'} - } - }; - map.removeParam('user.age.first'); - expect( - map, - equals({ - 'user': { - 'name': {'first': 'John', 'last': 'Doe'} - } - })); - }); - }); -} From c3cce3fe9754b3a04060e59731853952a94bf7f6 Mon Sep 17 00:00:00 2001 From: Javad Zobeidi <45537050+javad-zobeidi@users.noreply.github.com> Date: Thu, 2 Oct 2025 00:14:27 +0330 Subject: [PATCH 3/7] feat(request): add Request Helper for easier access to the request data --- lib/src/utils/request_helper.dart | 48 +++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 lib/src/utils/request_helper.dart diff --git a/lib/src/utils/request_helper.dart b/lib/src/utils/request_helper.dart new file mode 100644 index 0000000..1bd89c6 --- /dev/null +++ b/lib/src/utils/request_helper.dart @@ -0,0 +1,48 @@ +import 'dart:io'; + +import '../http/request/request_body.dart' show RequestBody; +import '../http/request/request_handler.dart' show globalHttpRequest; + +T? getParam(String key, [dynamic defualtValue]) { + dynamic param = globalHttpRequest!.uri.queryParameters[key]; + + if (param == null && defualtValue == null) { + return null; + } + + param ??= defualtValue; + + if (T.toString() == 'int') { + return int.tryParse(param.toString()) as T; + } + + if (T.toString() == 'bool') { + return bool.tryParse(param.toString()) as T; + } + + if (T.toString() == 'num') { + return num.tryParse(param.toString()) as T; + } + + if (T.toString() == 'double') { + return double.tryParse(param.toString()) as T; + } + + return param as T; +} + +Uri get requestUri => globalHttpRequest!.uri; +String? get clienIp => globalHttpRequest?.connectionInfo?.remoteAddress.address; +HttpHeaders? get requestHeaders => globalHttpRequest?.headers; +ContentType? get requestContentType => globalHttpRequest?.headers.contentType; +String? get method => globalHttpRequest?.method.toUpperCase(); +HttpResponse httpResponse = globalHttpRequest!.response; +Future requestBody() async { + final whereMethod = ['post', 'patch', 'put', 'delete'] + .where((method) => method == globalHttpRequest?.method.toLowerCase()) + .toList(); + if (whereMethod.isNotEmpty) { + return await RequestBody.extractBody(request: globalHttpRequest!); + } + return {}; +} From 2cc3a0c7456f917eb5efd68777a6ec9793da7193 Mon Sep 17 00:00:00 2001 From: Javad Zobeidi <45537050+javad-zobeidi@users.noreply.github.com> Date: Thu, 2 Oct 2025 00:16:28 +0330 Subject: [PATCH 4/7] feat(pagination): use RequestHelper.getParam for page retrieval --- lib/src/database/orm/model.dart | 5 +++-- .../query_builder/_query_executor_builder_impl.dart | 6 ++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/src/database/orm/model.dart b/lib/src/database/orm/model.dart index d9f4ef2..85e0689 100644 --- a/lib/src/database/orm/model.dart +++ b/lib/src/database/orm/model.dart @@ -10,6 +10,7 @@ import 'package:vania/src/exception/invalid_argument_exception.dart'; import 'package:vania/src/utils/_pluralize.dart'; import 'package:vania/src/utils/functions.dart'; +import '../../utils/request_helper.dart' show getParam; import 'belongs_to.dart'; import 'belongs_to_many.dart'; import 'has_many.dart'; @@ -549,7 +550,7 @@ abstract class Model extends QueryBuilderImpl { String? pageName, int? page, }) async { - int currentPage = page ?? 1; + int currentPage = page ?? getParam('page', 1)!; int total = await count(); final lastPage = (total / perPage).ceil(); final offset = (currentPage - 1) * perPage; @@ -590,7 +591,7 @@ abstract class Model extends QueryBuilderImpl { String? pageName, int? page, ]) async { - int currentPage = page ?? 1; + int currentPage = page ?? getParam('page', 1)!; int total = await count(); final lastPage = (total / perPage).ceil(); final offset = (currentPage - 1) * perPage; diff --git a/lib/src/database/query_builder/_query_executor_builder_impl.dart b/lib/src/database/query_builder/_query_executor_builder_impl.dart index 7c295fb..019259e 100644 --- a/lib/src/database/query_builder/_query_executor_builder_impl.dart +++ b/lib/src/database/query_builder/_query_executor_builder_impl.dart @@ -1,3 +1,5 @@ +import 'package:vania/src/utils/request_helper.dart' show getParam; + import '../../contract/database/_connectors/_database_connection.dart'; import '../../exception/invalid_argument_exception.dart'; @@ -327,7 +329,7 @@ abstract mixin class QueryExecutorBuilderImpl implements QueryBuilder { String? pageName, int? page, }) async { - int currentPage = page ?? 1; + int currentPage = page ?? getParam('page', 1)!; int total = await count(); final lastPage = (total / perPage).ceil(); final offset = (currentPage - 1) * perPage; @@ -369,7 +371,7 @@ abstract mixin class QueryExecutorBuilderImpl implements QueryBuilder { String? pageName, int? page, ]) async { - int currentPage = page ?? 1; + int currentPage = page ?? getParam('page', 1)!; int total = await count(); final lastPage = (total / perPage).ceil(); final offset = (currentPage - 1) * perPage; From 196b223a6af85ef60fc0b65d81cff4824f32b0d5 Mon Sep 17 00:00:00 2001 From: Javad Zobeidi <45537050+javad-zobeidi@users.noreply.github.com> Date: Thu, 2 Oct 2025 00:18:35 +0330 Subject: [PATCH 5/7] feat(validation): add builder pattern support for defining validation rules --- lib/src/http/request/request.dart | 72 +++++++++++++++-- lib/src/http/request/request_handler.dart | 6 +- lib/src/http/validation/rules.dart | 97 ++++++++++++++--------- lib/src/http/validation/validator.dart | 4 + lib/src/utils/helper.dart | 3 + 5 files changed, 138 insertions(+), 44 deletions(-) diff --git a/lib/src/http/request/request.dart b/lib/src/http/request/request.dart index bd3a797..66c05b4 100644 --- a/lib/src/http/request/request.dart +++ b/lib/src/http/request/request.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:vania/src/authentication/authentication.dart'; +import 'package:vania/src/contract/http/request/form_validation.dart'; import 'package:vania/src/exception/validation_exception.dart'; import 'package:vania/src/http/request/request_body.dart'; import 'package:vania/src/http/request/request_file.dart'; @@ -12,11 +13,19 @@ import 'package:vania/src/http/validation/validator.dart'; import 'package:vania/src/route/route_data.dart'; import 'package:vania/src/view_engine/template_engine.dart'; +import '../../exception/unauthorized_exception.dart'; +import '../validation/field_validation.dart'; + class Request { - final HttpRequest request; - final RouteData? route; + late HttpRequest request; + RouteData? route; + + Request from({required HttpRequest request, RouteData? route}) { + this.request = request; + this.route = route; - Request.from({required this.request, this.route}); + return this; + } Map? get user => Auth().user(); @@ -339,16 +348,67 @@ class Request { dynamic rules, [ Map messages = const {}, ]) async { - assert(rules is Map || rules is List, - 'Rules must be either Map or List.'); + assert( + rules is Map || + rules is List || + rules is List || + rules is FormValidation, + 'Rules must be either Map or List. or FormRequest'); TemplateEngine().sessionErrors.clear(); if (rules is Map) { await _validate(rules, messages); + } else if (rules is List) { + Map ruleMessages = Map.from(messages); + final rulesMap = Map.fromEntries(rules.map((rule) { + ruleMessages.addAll(rule.toMapMessages); + return MapEntry(rule.fieldName, rule.toString()); + })); + await _validate(rulesMap, ruleMessages); + } else if (rules is FormValidation) { + await _formRequestValidate(rules); } else { _validateChain(rules as List); } } + Future _formRequestValidate(FormValidation formRequest) async { + if (!formRequest.authorize()) { + throw UnauthorizedException(message: 'Access denied', code: 403); + } + + final rules = formRequest.rules(); + final Map messages = formRequest.messages(); + + Validator validator = Validator(data: body); + + if (formRequest.customRule().isNotEmpty) { + validator.customRule(formRequest.customRule()); + } + + Map rulesMap = {}; + if (rules is List) { + rulesMap = Map.fromEntries(rules.map((rule) { + messages.addAll(rule.toMapMessages); + return MapEntry(rule.fieldName, rule.toString()); + })); + } else { + rulesMap = formRequest.rules(); + } + + if (messages.isNotEmpty) { + validator.setNewMessages(messages); + } + + await validator.validate(rulesMap); + if (validator.hasError) { + bool isHtml = request.headers.value('accept').toString().contains('html'); + if (isHtml) { + TemplateEngine().sessionErrors.addAll(validator.errors); + } + throw ValidationException(message: validator.errors); + } + } + Future _validate( Map rules, [ Map messages = const {}, @@ -381,7 +441,7 @@ class Request { for (ValidationRule rule in validation.rules) { if (!rule.validate(fieldValue, data)) { errors[validation.field] = rule.errorMessage; - break; // Stop at the first failed validation per field + break; } } } diff --git a/lib/src/http/request/request_handler.dart b/lib/src/http/request/request_handler.dart index bcbc6db..c3844ee 100644 --- a/lib/src/http/request/request_handler.dart +++ b/lib/src/http/request/request_handler.dart @@ -26,6 +26,8 @@ import 'package:vania/src/logger/logger.dart'; import 'package:vania/src/utils/helper.dart'; import 'request.dart'; +HttpRequest? globalHttpRequest; + class RequestHandler { /// Handles HTTP requests, determining if the request is a WebSocket upgrade or /// a standard HTTP request. If it's a WebSocket request, it delegates handling @@ -38,6 +40,8 @@ class RequestHandler { /// - [BaseHttpResponseException] if there is an issue with the HTTP response. /// - [InvalidArgumentException] if an invalid argument is encountered. Future handle(HttpRequest req) async { + globalHttpRequest = req; + /// Check the incoming request is web socket or not if (env('APP_WEBSOCKET', false) && WebSocketTransformer.isUpgradeRequest(req)) { @@ -53,7 +57,7 @@ class RequestHandler { String requestMethod = req.method.toUpperCase(); if (route != null) { - Request request = Request.from(request: req, route: route); + Request request = Request().from(request: req, route: route); await request.extractBody(); if (isHtml) { diff --git a/lib/src/http/validation/rules.dart b/lib/src/http/validation/rules.dart index 2d6cb0c..6efd35a 100644 --- a/lib/src/http/validation/rules.dart +++ b/lib/src/http/validation/rules.dart @@ -1,7 +1,8 @@ +import 'package:vania/src/database/db.dart'; import 'package:vania/src/http/request/request_file.dart'; class Rules { - /// check field is required + /// Check field is required static bool isRequired( Map data, dynamic value, String args) { if (value == null) { @@ -13,19 +14,19 @@ class Rules { return value.toString().isNotEmpty; } - /// check field is email + /// Check field is email static bool isEmail(Map data, dynamic value, String args) { RegExp emailRegex = RegExp( r'^[\w-]+(\.[\w-]+)*@[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*(\.[a-zA-Z]{2,})$'); return emailRegex.hasMatch(value.toString()); } - /// check field is string + /// Check field is string static bool isString(Map data, dynamic value, String args) { return value is String; } - /// check field is number + /// Check field is number static bool isNumeric(Map data, dynamic value, String args) { try { num.parse(value.toString()); @@ -35,7 +36,7 @@ class Rules { } } - /// check field is ip address + /// Check field is ip address static bool isIp(Map data, dynamic value, String args) { RegExp ipAddressRegex = RegExp( r'^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$', @@ -43,12 +44,17 @@ class Rules { return ipAddressRegex.hasMatch(value.toString()); } - /// check field is boolean + /// Check field is boolean static bool isBoolean(Map data, dynamic value, String args) { - return value is bool; + try { + bool.parse(value.toString()); + return true; + } catch (e) { + return false; + } } - /// check field is integer + /// Check field is integer static bool isInteger(Map data, dynamic value, String args) { try { int.parse(value.toString()); @@ -58,7 +64,7 @@ class Rules { } } - /// check field is double + /// Check field is double static bool isDouble(Map data, dynamic value, String args) { try { double.parse(value.toString()); @@ -68,37 +74,37 @@ class Rules { } } - /// check field is array + /// Check field is array static bool isArray(Map data, dynamic value, String args) { return value is List; } - /// check field is map or json + /// Check field is map or json static bool isJson(Map data, dynamic value, String args) { return value is Map; } - /// check field is alphabetic + /// Check field is alphabetic static bool isAlpha(Map data, dynamic value, String args) { RegExp alphabeticRegex = RegExp(r'^[a-zA-Z]+$'); return alphabeticRegex.hasMatch(value.toString()); } - /// check field is only with alphabetic, dash or underscore + /// Check field is only with alphabetic, dash or underscore static bool isAlphaDash( Map data, dynamic value, String args) { RegExp alphaDashRegex = RegExp(r'^[a-zA-Z-_]+$'); return alphaDashRegex.hasMatch(value.toString()); } - /// check field is only with alphabetic, number + /// Check field is only with alphabetic, number static bool isAlphaNumeric( Map data, dynamic value, String args) { RegExp alphaNumericRegex = RegExp(r'^[a-zA-Z0-9]+$'); return alphaNumericRegex.hasMatch(value.toString()); } - /// check field is a date or date time + /// Check field is a date or date time static bool isDate(Map data, dynamic value, String args) { try { DateTime? dateTime = DateTime.tryParse(value.toString()); @@ -108,7 +114,7 @@ class Rules { } } - /// check field is a valid url + /// Check field is a valid url static bool isUrl(Map data, dynamic value, String args) { try { Uri? uri = Uri.tryParse(value); @@ -118,24 +124,24 @@ class Rules { } } - /// check field is a valid uuid + /// Check field is a valid uuid static bool isUUID(Map data, dynamic value, String args) { RegExp uuidRegex = RegExp( r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'); return uuidRegex.hasMatch(value.toString()); } - /// check field character is given max length + /// Check field character is given max length static bool maxLength(Map data, dynamic value, String max) { return value.toString().length <= num.parse(max.toString()); } - /// check field character is given min length + /// Check field character is given min length static bool minLength(Map data, dynamic value, String min) { return value.toString().length >= num.parse(min.toString()); } - /// check field character is between given length + /// Check field character is between given length static bool lengthBetween( Map data, dynamic value, String values) { List parts = values.toString().split(','); @@ -148,7 +154,21 @@ class Rules { return value >= value2 && value <= value1; } - /// check field is between given value + /// Check field is unique + static Future unique( + Map data, + dynamic value, + String values, + ) async { + List parts = values.toString().split(','); + String table = parts[0]; + String column = parts[1]; + final exists = + await DB.table(table).whereEqualTo(column, value).doesntExist(); + return exists; + } + + /// Check field is between given value static bool between(Map data, dynamic value, String values) { List parts = values.toString().split(','); num value1 = num.parse(parts[0]); @@ -160,78 +180,81 @@ class Rules { return value >= value2 && value <= value1; } - /// check field is greater than given value + /// Check field is greater than given value static bool greaterThan( Map data, dynamic value, String compare) { value = num.parse(value.toString()); return value > num.parse(compare.toString()); } - /// check field is less than given value + /// Check field is less than given value static bool lessThan( Map data, dynamic value, String compare) { value = num.parse(value.toString()); return value < num.parse(compare.toString()); } - /// check field is reach min value + /// Check field is reach min value static bool min(Map data, dynamic value, String compare) { value = num.parse(value.toString()); return value >= num.parse(compare.toString()); } - /// check field is not over max value + /// Check field is not over max value static bool max(Map data, dynamic value, String compare) { value = num.parse(value.toString()); return value <= num.parse(compare.toString()); } - /// check field is in given array + /// Check field is in given array static bool inArray(Map data, dynamic value, String arr) { List array = arr.toString().split(','); return array.contains(value.toString()); } - /// check field is not in given array + /// Check field is not in given array static bool notInArray(Map data, dynamic value, String arr) { List array = arr.toString().split(','); return !array.contains(value.toString()); } - /// check field start with given text + /// Check field start with given text static bool startWith( Map data, dynamic value, String start) { return value.toString().startsWith(start.toString()); } - /// check field end with given text + /// Check field end with given text static bool endWith(Map data, dynamic value, String end) { return value.toString().endsWith(end.toString()); } - /// check 2 password are matched + /// Check 2 password are matched static bool confirmed(Map data, dynamic value, String key) { key = key.toString().isEmpty ? 'password_confirmation' : key; dynamic confirmValue = data[key]; return confirmValue == value; } - /// check field is required when condition is matched + /// Check field is required when condition is matched static bool requiredIf( - Map data, dynamic value, String payload) { + Map data, + dynamic value, + String payload, + ) { List parts = payload.toString().split(','); String secondField = parts[0]; String secondFieldValueFromRule = parts[1].toString(); String? secondFieldValueFromRequest = data[secondField].toString(); - /// check only when req value and rule value are same + /// Check only when req value and rule value are same if (secondFieldValueFromRule == secondFieldValueFromRequest) { return isRequired(data, value, ''); } return true; } - /// check field is required when condition is not matched + /// Check field is required when condition is not matched static bool requiredIfNot( Map data, dynamic value, String payload) { List parts = payload.toString().split(','); @@ -239,14 +262,14 @@ class Rules { String secondFieldValueFromRule = parts[1].toString(); String? secondFieldValueFromRequest = data[secondField].toString(); - /// check only when req value and rule value are same + /// Check only when req value and rule value are same if (secondFieldValueFromRule != secondFieldValueFromRequest) { return isRequired(data, value, ''); } return true; } - /// check field is valid image + /// Check field is valid image static bool isImage(Map data, dynamic value, String args) { if (value is! RequestFile) { return false; @@ -271,7 +294,7 @@ class Rules { return false; } - /// check field is a file + /// Check field is a file /// not a file => false /// if added supported extension in validation, check with extension /// return true diff --git a/lib/src/http/validation/validator.dart b/lib/src/http/validation/validator.dart index c29d85f..503d593 100644 --- a/lib/src/http/validation/validator.dart +++ b/lib/src/http/validation/validator.dart @@ -288,5 +288,9 @@ class Validator { 'message': 'The {field} is either invalid or unsupported extension', 'function': Rules.regExp, }, + 'unique': { + 'message': 'This {field} is exist', + 'function': Rules.unique, + }, }; } diff --git a/lib/src/utils/helper.dart b/lib/src/utils/helper.dart index 24c2c1d..a5335b0 100644 --- a/lib/src/utils/helper.dart +++ b/lib/src/utils/helper.dart @@ -7,6 +7,7 @@ import 'package:vania/src/localization_handler/localization.dart'; import 'package:vania/src/view_engine/template_engine.dart'; import '../http/session/session_manager.dart'; +import '../http/validation/field_validation.dart'; String storagePath(String file) => 'storage/$file'; @@ -16,6 +17,8 @@ String url(String path) => '${env('APP_URL')}/$path'; String assets(String src) => url(src); +FieldValidation field(String fieldName) => FieldValidation(fieldName); + bool can(String ability) => Gate().allows(ability); bool cannot(String ability) => Gate().denies(ability); From ca97976fdae43312fb64ebe4df991d2a88cc6b5a Mon Sep 17 00:00:00 2001 From: Javad Zobeidi <45537050+javad-zobeidi@users.noreply.github.com> Date: Thu, 2 Oct 2025 00:19:12 +0330 Subject: [PATCH 6/7] fix bugs --- lib/http/request.dart | 1 + lib/src/extensions/map_extension.dart | 11 ++++++++--- lib/src/route/route_handler.dart | 4 +++- lib/vania.dart | 2 ++ 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/lib/http/request.dart b/lib/http/request.dart index 49f51ce..33d6161 100644 --- a/lib/http/request.dart +++ b/lib/http/request.dart @@ -5,3 +5,4 @@ export '../src/http/validation/custom_validation_rule.dart'; export '../src/exception/base_http_exception.dart'; export '../src/exception/http_exception.dart'; export '../src/exception/redirect_exception.dart'; +export '../src/utils/request_helper.dart'; diff --git a/lib/src/extensions/map_extension.dart b/lib/src/extensions/map_extension.dart index 78af89a..960b765 100644 --- a/lib/src/extensions/map_extension.dart +++ b/lib/src/extensions/map_extension.dart @@ -1,4 +1,4 @@ -extension MapExtensions on Map { +extension MapExtensions on Map { /// Removes a parameter from a nested map structure based on a dot-separated key path. /// /// This method allows removing a value from a deeply nested map using a path described by a string of keys separated by dots. @@ -65,14 +65,19 @@ extension MapExtensions on Map { /// ``` dynamic getParam(String keys) { List parts = keys.split('.'); - Map data = this; + Map data = this; for (int i = 0; i < parts.length - 1; i++) { if (data[parts[i]] is Map) { data = data[parts[i]]; } else { - return null; // Return null if the path breaks + return []; } } + if (data[parts.last] is List) { + List> list = + List.castFrom>(data[parts.last]); + return list; + } return data[parts.last]; } } diff --git a/lib/src/route/route_handler.dart b/lib/src/route/route_handler.dart index d9d8a9d..42035e2 100644 --- a/lib/src/route/route_handler.dart +++ b/lib/src/route/route_handler.dart @@ -7,9 +7,11 @@ import 'package:vania/src/route/route_data.dart'; import 'package:vania/src/route/router.dart'; import 'package:vania/src/route/set_static_path.dart'; import 'package:vania/src/utils/functions.dart'; +import 'package:vania/src/utils/helper.dart' show env; final Map _regexCache = {}; -final _lookupCache = _LruCache<_LookupKey, RouteData?>(2000); +final _lookupCache = + _LruCache<_LookupKey, RouteData?>(env("ROUTE_LOOK_UP_SIZE", 2000)); final Map> _staticRoutes = {}; final List _dynamicRoutes = []; diff --git a/lib/vania.dart b/lib/vania.dart index a0540a5..1c03ee7 100644 --- a/lib/vania.dart +++ b/lib/vania.dart @@ -20,3 +20,5 @@ export 'src/exception/invalid_argument_exception.dart'; export 'src/exception/throttle_exception.dart'; export 'src/exception/validation_exception.dart'; export 'src/exception/unauthenticated.dart'; + +export 'src/contract/http/request/form_validation.dart'; From 7c4e343c782cc89731c06dd95a71f9cecb0c6cc2 Mon Sep 17 00:00:00 2001 From: Javad Zobeidi <45537050+javad-zobeidi@users.noreply.github.com> Date: Thu, 2 Oct 2025 00:20:08 +0330 Subject: [PATCH 7/7] improvement(exception): enhance exception handling and messages --- lib/src/http/controller/controller_handler.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/src/http/controller/controller_handler.dart b/lib/src/http/controller/controller_handler.dart index c566232..63ff5dc 100644 --- a/lib/src/http/controller/controller_handler.dart +++ b/lib/src/http/controller/controller_handler.dart @@ -6,6 +6,7 @@ import 'package:vania/src/http/response/response.dart'; import 'package:vania/src/route/route_data.dart'; import 'package:vania/src/route/route_history.dart'; +import '../../exception/base_http_exception.dart'; import '../../exception/invalid_argument_exception.dart'; class ControllerHandler { @@ -67,6 +68,8 @@ class ControllerHandler { _response(request, error.message, 500); } on QueryException catch (error) { _response(request, error.cause ?? '', 500); + } on BaseHttpResponseException catch (error) { + _response(request, error.message, error.code); } catch (error) { _response(request, error.toString()); }