diff --git a/melos.yaml b/melos.yaml index 3e2378fe..8c3cfca9 100644 --- a/melos.yaml +++ b/melos.yaml @@ -18,6 +18,8 @@ scripts: test: melos exec --dir-exists=test --no-private -- dart test + pub:upgrade: melos exec --no-flutter -- dart pub upgrade + doc: melos exec --no-private -- dart doc . publish:force: dart pub publish -f \ No newline at end of file diff --git a/packages/dartness_generator/CHANGELOG.md b/packages/dartness_generator/CHANGELOG.md index d73bcc22..6db84e55 100644 --- a/packages/dartness_generator/CHANGELOG.md +++ b/packages/dartness_generator/CHANGELOG.md @@ -32,10 +32,14 @@ - Fixed error with topological sort when using `useFactory` - Controller path variable is optional +## 0.6.1 + +- Updated dartness_server version + ## 0.7.0 - Added schedulers ## 0.7.1 -- Fixed Dartness server version package \ No newline at end of file +- Fixed Dartness server version package diff --git a/packages/dartness_server/CHANGELOG.md b/packages/dartness_server/CHANGELOG.md index 5440c62c..82f67a30 100644 --- a/packages/dartness_server/CHANGELOG.md +++ b/packages/dartness_server/CHANGELOG.md @@ -49,6 +49,11 @@ - Added https exceptions + +## 0.6.1 + +- Fixed error with QueryParam as a List + ## 0.7.0 -- Added schedulers \ No newline at end of file +- Added schedulers diff --git a/packages/dartness_server/README.md b/packages/dartness_server/README.md index babdb345..abd61eb7 100644 --- a/packages/dartness_server/README.md +++ b/packages/dartness_server/README.md @@ -51,11 +51,11 @@ $ dart create -t console your_project_name ```yaml dependencies: - dartness_server: ^0.6.0 + dartness_server: ^0.6.1 dev_dependencies: build_runner: ^2.2.0 - dartness_generator: ^0.6.0 + dartness_generator: ^0.6.1 ``` 2. Create the file in "bin/main.dart" diff --git a/packages/dartness_server/lib/exception.dart b/packages/dartness_server/lib/exception.dart index 84392682..67f6e4ac 100644 --- a/packages/dartness_server/lib/exception.dart +++ b/packages/dartness_server/lib/exception.dart @@ -7,3 +7,19 @@ export 'src/exception/dartness_catch_handler.dart'; export 'src/exception/dartness_error_handler.dart'; export 'src/exception/dartness_error_handler_register.dart'; export 'src/exception/http_status_code_exception.dart'; +export 'src/exception/bad_gateway_exception.dart'; +export 'src/exception/bad_request_exception.dart'; +export 'src/exception/conflict_exception.dart'; +export 'src/exception/forbidden_exception.dart'; +export 'src/exception/gateway_timeout_exception.dart'; +export 'src/exception/gone_exception.dart'; +export 'src/exception/internal_server_error_exception.dart'; +export 'src/exception/method_not_allowed_exception.dart'; +export 'src/exception/not_acceptable_exception.dart'; +export 'src/exception/not_found_exception.dart'; +export 'src/exception/not_implemented_exception.dart'; +export 'src/exception/service_unavailable_exception.dart'; +export 'src/exception/too_many_requests_exception.dart'; +export 'src/exception/unauthorized_exception.dart'; +export 'src/exception/unprocessable_entity_exception.dart'; +export 'src/exception/unsupported_media_type_exception.dart'; diff --git a/packages/dartness_server/lib/schedule.dart b/packages/dartness_server/lib/schedule.dart index 8d02c180..f76aa40f 100644 --- a/packages/dartness_server/lib/schedule.dart +++ b/packages/dartness_server/lib/schedule.dart @@ -5,3 +5,4 @@ export 'src/configuration/schedule/scheduled.dart'; export 'src/configuration/schedule/scheduler.dart'; export 'src/configuration/schedule/time_unit.dart'; export 'src/configuration/schedule/scheduler_manager.dart'; + diff --git a/packages/dartness_server/lib/src/configuration/injectable.dart b/packages/dartness_server/lib/src/configuration/injectable.dart index 7c4343b4..f54dd6aa 100644 --- a/packages/dartness_server/lib/src/configuration/injectable.dart +++ b/packages/dartness_server/lib/src/configuration/injectable.dart @@ -1,6 +1,7 @@ /// A metadata annotation used to mark a class as available to the dependency injection system. /// /// This annotation can be applied to classes which should be instantiated or managed by the DI framework. +/// /// Marking a class with `@Injectable` indicates to the DI framework that an instance of the class /// can be created and provided to other parts of the application that require it. /// diff --git a/packages/dartness_server/lib/src/configuration/schedule/cron_expression.dart b/packages/dartness_server/lib/src/configuration/schedule/cron_expression.dart new file mode 100644 index 00000000..b8e7b77f --- /dev/null +++ b/packages/dartness_server/lib/src/configuration/schedule/cron_expression.dart @@ -0,0 +1,87 @@ +/// A class representing a cron expression, used for scheduling tasks. +class CronExpression { + final List seconds; + final List minutes; + final List hours; + final List days; + final List months; + final List weekdays; + + CronExpression({ + required this.seconds, + required this.minutes, + required this.hours, + required this.days, + required this.months, + required this.weekdays, + }); + + /// Parses a cron string into its components. + /// + /// The cron string must have 6 parts, separated by spaces, representing: + /// seconds, minutes, hours, day of month, month, and day of week. + factory CronExpression.parse(String cronString) { + final parts = cronString.split(' '); + if (parts.length != 6) { + throw FormatException('Invalid cron string length'); + } + + // Validate and parse each field + return CronExpression( + seconds: _parseField(parts[0], 0, 59), + minutes: _parseField(parts[1], 0, 59), + hours: _parseField(parts[2], 0, 23), + days: _parseField(parts[3], 1, 31), + months: _parseField(parts[4], 1, 12), + weekdays: _parseField(parts[5], 0, 7), + ); + } + + static List _parseField(String field, int min, int max) { + // Wildcard + if (field == '*') { + return [field]; + } + + // Step values + if (field.contains('/')) { + final stepParts = field.split('/'); + if (stepParts[0] != '*' && + !stepParts[0].contains('-') && + int.tryParse(stepParts[1]) == null) { + throw FormatException('Unsupported or malformed step value'); + } + if (stepParts[0] == '*' && int.parse(stepParts[1]) > max) { + throw FormatException('Step value out of range'); + } + return [field]; + } + + // Range values + if (field.contains('-')) { + final rangeParts = field.split('-').map(int.parse).toList(); + if (rangeParts[0] > rangeParts[1]) { + throw FormatException('Invalid range'); + } + if (rangeParts.any((val) => val < min || val > max)) { + throw FormatException('Range value out of bounds'); + } + return [field]; + } + + // List values + final listParts = field.split(','); + for (var part in listParts) { + final value = int.tryParse(part); + if (value == null || value < min || value > max) { + throw FormatException('Value out of bounds'); + } + } + return listParts; + } + + @override + String toString() { + return 'CronExpression(seconds: $seconds, minutes: $minutes, hours: $hours, days: $days, months: $months, weekdays: $weekdays)'; + } +} diff --git a/packages/dartness_server/lib/src/configuration/schedule/cron_scheduler.dart b/packages/dartness_server/lib/src/configuration/schedule/cron_scheduler.dart new file mode 100644 index 00000000..30aff331 --- /dev/null +++ b/packages/dartness_server/lib/src/configuration/schedule/cron_scheduler.dart @@ -0,0 +1,52 @@ +import 'dart:async'; + +import 'cron_expression.dart'; +import 'system_time_provider.dart'; +import 'time_provider.dart'; + +/// Scheduler class that uses a [CronExpression] to execute tasks periodically. +class CronScheduler { + final CronExpression cronExpression; + // TimeProvider allows for injecting a custom time source, facilitating testing. + final TimeProvider timeProvider; + Timer? _timer; + + // Constructor accepts a cron expression and an optional custom TimeProvider. + // If no TimeProvider is provided, it defaults to the system clock. + CronScheduler(this.cronExpression, {TimeProvider? timeProvider}) + : timeProvider = timeProvider ?? SystemTimeProvider(); + + /// Starts the periodic execution of a task based on the cron expression. + /// [task] is a function with no parameters that encapsulates + /// the invocation of the actual task function with its parameters. + void start(void Function() task) { + _timer = Timer.periodic(Duration(seconds: 1), (Timer t) { + var now = timeProvider.now(); + // Checks if the current time matches the cron expression before executing the task. + if (_matches(now)) { + task(); + } + }); + } + + /// Checks if the current time matches the cron expression. + bool _matches(final DateTime dateTime) { + return (cronExpression.seconds.contains('*') || + cronExpression.seconds.contains(dateTime.second.toString())) && + (cronExpression.minutes.contains('*') || + cronExpression.minutes.contains(dateTime.minute.toString())) && + (cronExpression.hours.contains('*') || + cronExpression.hours.contains(dateTime.hour.toString())) && + (cronExpression.days.contains('*') || + cronExpression.days.contains(dateTime.day.toString())) && + (cronExpression.months.contains('*') || + cronExpression.months.contains(dateTime.month.toString())) && + (cronExpression.weekdays.contains('*') || + cronExpression.weekdays.contains(dateTime.weekday.toString())); + } + + /// Stops the periodic execution of the task. + void stop() { + _timer?.cancel(); + } +} diff --git a/packages/dartness_server/lib/src/configuration/schedule/scheduled.dart b/packages/dartness_server/lib/src/configuration/schedule/scheduled.dart index 80558ab9..ee460e27 100644 --- a/packages/dartness_server/lib/src/configuration/schedule/scheduled.dart +++ b/packages/dartness_server/lib/src/configuration/schedule/scheduled.dart @@ -1,10 +1,12 @@ -/// Represents an annotation for scheduling task +import 'package:dartness_server/src/configuration/schedule/time_unit.dart'; + +/// Represents an annotation for scheduling tasks with various parameters. class Scheduled { /// A cron-like expression defining the triggers for the scheduled method. - final String cron; + final String? cron; /// Constructor for the Scheduled annotation. const Scheduled({ - required this.cron, + this.cron, }); } diff --git a/packages/dartness_server/lib/src/configuration/schedule/system_time_provider.dart b/packages/dartness_server/lib/src/configuration/schedule/system_time_provider.dart new file mode 100644 index 00000000..f829ba6f --- /dev/null +++ b/packages/dartness_server/lib/src/configuration/schedule/system_time_provider.dart @@ -0,0 +1,7 @@ +import 'time_provider.dart'; + +// Default implementation of TimeProvider using the system clock. +class SystemTimeProvider implements TimeProvider { + @override + DateTime now() => DateTime.now(); +} diff --git a/packages/dartness_server/lib/src/configuration/schedule/time_provider.dart b/packages/dartness_server/lib/src/configuration/schedule/time_provider.dart new file mode 100644 index 00000000..a1d2223e --- /dev/null +++ b/packages/dartness_server/lib/src/configuration/schedule/time_provider.dart @@ -0,0 +1,5 @@ +// Abstract class defining the interface for a time provider. +abstract class TimeProvider { + // Method that should return the current time. + DateTime now(); +} \ No newline at end of file diff --git a/packages/dartness_server/lib/src/exception/unsupported_media_type_exception.dart b/packages/dartness_server/lib/src/exception/unsupported_media_type_exception.dart index 5609f8cd..3c30fb10 100644 --- a/packages/dartness_server/lib/src/exception/unsupported_media_type_exception.dart +++ b/packages/dartness_server/lib/src/exception/unsupported_media_type_exception.dart @@ -8,10 +8,10 @@ import 'package:dartness_server/src/exception/http_client_error_exception.dart'; /// that the server understands the content type of the request entity but was /// unable to process the contained instructions. It includes a human-readable /// [message] and has an HTTP status code of [HttpStatus.unprocessableEntity]. -class UnprocessableEntityException extends HttpClientErrorException { - /// Creates an [UnprocessableEntityException] with the specified [message]. +class UnsupportedMediaTypeException extends HttpClientErrorException { + /// Creates an [UnsupportedMediaTypeException] with the specified [message]. /// /// The [message] is a human-readable description of the unprocessable entity error. - const UnprocessableEntityException(String message) + const UnsupportedMediaTypeException(String message) : super(message, HttpStatus.unprocessableEntity); } diff --git a/packages/dartness_server/lib/src/route/dartness_router_handler.dart b/packages/dartness_server/lib/src/route/dartness_router_handler.dart index 69d5253a..0e7cc744 100644 --- a/packages/dartness_server/lib/src/route/dartness_router_handler.dart +++ b/packages/dartness_server/lib/src/route/dartness_router_handler.dart @@ -46,12 +46,20 @@ class DartnessRouterHandler { } else { if (param.isPath) { final pathParam = _getPathParam(request, param); - final value = pathParam.stringToType(param.type); - namedArguments[Symbol(param.name)] = value; + if (pathParam is String) { + final value = pathParam.stringToType(param.type); + namedArguments[Symbol(param.name)] = value; + } else { + namedArguments[Symbol(param.name)] = pathParam; + } } else { - final String? queryParam = _getQueryParam(request, param); - final value = queryParam?.stringToType(param.type); - namedArguments[Symbol(param.name)] = value; + final Object? queryParam = _getQueryParam(request, param); + if (queryParam is String) { + final value = queryParam.stringToType(param.type); + namedArguments[Symbol(param.name)] = value; + } else { + namedArguments[Symbol(param.name)] = queryParam; + } } } } @@ -87,9 +95,15 @@ class DartnessRouterHandler { ); } - dynamic _getQueryParam(Request request, DartnessParam param) => - request.url.queryParameters[param.name] ?? param.defaultValue; + Object? _getQueryParam(final Request request, final DartnessParam param) { + final all = request.url.queryParametersAll; + final foundAll = all[param.name]; + if (foundAll != null && foundAll.length > 1) { + return all[param.name]; + } + return request.url.queryParameters[param.name] ?? param.defaultValue; + } - dynamic _getPathParam(Request request, DartnessParam param) => + Object? _getPathParam(final Request request, final DartnessParam param) => request.params[param.name] ?? param.defaultValue; } diff --git a/packages/dartness_server/pubspec.yaml b/packages/dartness_server/pubspec.yaml index 8d49d2ab..0da71106 100644 --- a/packages/dartness_server/pubspec.yaml +++ b/packages/dartness_server/pubspec.yaml @@ -25,4 +25,4 @@ dev_dependencies: http: ^1.2.1 lints: ^4.0.0 test: ^1.25.7 - build_runner: ^2.4.11 \ No newline at end of file + build_runner: ^2.4.11 diff --git a/packages/dartness_server/test/lib/src/configuration/schedue/cron_expression_test.dart b/packages/dartness_server/test/lib/src/configuration/schedue/cron_expression_test.dart new file mode 100644 index 00000000..1cb01b51 --- /dev/null +++ b/packages/dartness_server/test/lib/src/configuration/schedue/cron_expression_test.dart @@ -0,0 +1,75 @@ +import 'package:dartness_server/schedule.dart'; +import 'package:test/test.dart'; + +void main() { + group('CronExpression Parsing', () { + test('parses wildcard expressions correctly', () { + final expression = CronExpression.parse('* * * * * *'); + expect(expression.seconds, equals(['*'])); + expect(expression.minutes, equals(['*'])); + expect(expression.hours, equals(['*'])); + expect(expression.days, equals(['*'])); + expect(expression.months, equals(['*'])); + expect(expression.weekdays, equals(['*'])); + }); + + test('parses specific value expressions correctly', () { + final expression = CronExpression.parse('5 30 2 15 6 3'); + expect(expression.seconds, equals(['5'])); + expect(expression.minutes, equals(['30'])); + expect(expression.hours, equals(['2'])); + expect(expression.days, equals(['15'])); + expect(expression.months, equals(['6'])); + expect(expression.weekdays, equals(['3'])); + }); + + test('parses list expressions correctly', () { + final expression = CronExpression.parse('1,5 10,20,30 * * * *'); + expect(expression.seconds, equals(['1', '5'])); + expect(expression.minutes, equals(['10', '20', '30'])); + expect(expression.hours, contains('*')); + }); + + test('parses range expressions correctly', () { + final expression = CronExpression.parse('1-5 10-12 * * * *'); + expect(expression.seconds, equals(['1-5'])); + expect(expression.minutes, equals(['10-12'])); + }); + + test('parses step values correctly', () { + final expression = CronExpression.parse('*/5 */10 * * * *'); + expect(expression.seconds, equals(['*/5'])); + expect(expression.minutes, equals(['*/10'])); + }); + + test('throws FormatException for invalid cron string length', () { + expect(() => CronExpression.parse('5 4 3 2'), + throwsA(isA())); + }); + + test('throws FormatException for out-of-range values', () { + // Assuming seconds are 0-59, minutes are 0-59, and so on. + // This example checks for an out-of-range minute value. + expect(() => CronExpression.parse('0 60 * * * *'), + throwsA(isA())); + }); + + test('throws FormatException for invalid characters', () { + // This test assumes the parser does not support special characters or letters + expect(() => CronExpression.parse('* * * * * L'), + throwsA(isA())); + }); + + test('throws FormatException for invalid range', () { + // Example: Start of range is greater than end + expect(() => CronExpression.parse('30-5 * * * * *'), + throwsA(isA())); + }); + + test('throws FormatException for unsupported step values', () { + // Example: Step value is used where not supported or is malformed + expect(() => CronExpression.parse('*/60 * * * * *'), + throwsA(isA())); + }); + }); +} diff --git a/packages/dartness_server/test/lib/src/configuration/schedue/cron_scheduler_test.dart b/packages/dartness_server/test/lib/src/configuration/schedue/cron_scheduler_test.dart new file mode 100644 index 00000000..6e287b09 --- /dev/null +++ b/packages/dartness_server/test/lib/src/configuration/schedue/cron_scheduler_test.dart @@ -0,0 +1,110 @@ +import 'package:dartness_server/schedule.dart'; +import 'package:dartness_server/src/configuration/schedule/time_provider.dart'; +import 'package:test/test.dart'; +import 'package:fake_async/fake_async.dart'; + +// Mock implementation of TimeProvider for testing purposes. +class MockTimeProvider implements TimeProvider { + // The mock time to be returned by now(). + DateTime fakeTime; + + MockTimeProvider(this.fakeTime); + + @override + DateTime now() => fakeTime; + + // Allows tests to simulate the passage of time by advancing the fakeTime. + void advanceTime(Duration duration) { + fakeTime = fakeTime.add(duration); + } +} + +void main() { + group('CronScheduler', () { + test('starts and stops without errors', () async { + // Arrange + // Every second + var cronExpression = CronExpression.parse('* * * * * *'); + var scheduler = CronScheduler(cronExpression); + var taskExecuted = false; + + // Act + scheduler.start(() => taskExecuted = true); + // Wait for at least one cycle + await Future.delayed(Duration(seconds: 2)); + scheduler.stop(); + + // Assert + expect(taskExecuted, isTrue); + }); + + test('executes task every second', () async { + // Arrange + var cronExpression = CronExpression.parse('* * * * * *'); // Every second + var scheduler = CronScheduler(cronExpression); + var executionCount = 0; + void task() => executionCount++; + + // Act + // Wait to accumulate executions + scheduler.start(task); + await Future.delayed(Duration( + seconds: 5, + )); + scheduler.stop(); + + // Assert + // Accounting for potential timing issues + expect(executionCount, greaterThanOrEqualTo(4)); + }); + + test('does not execute task after being stopped', () async { + // Arrange + var cronExpression = CronExpression.parse('* * * * * *'); // Every second + var scheduler = CronScheduler(cronExpression); + var executionCount = 0; + void task() => executionCount++; + + // Act + scheduler.start(task); + // Allow some executions + await Future.delayed(Duration(seconds: 2)); + scheduler.stop(); + var countAfterStop = executionCount; + // Wait to see if it stops executing + await Future.delayed(Duration( + seconds: 2, + )); + + // Assert + expect(executionCount, countAfterStop); + }); + }); + + group('CronScheduler with Mock Time', () { + test('executes task when time matches', () { + fakeAsync((async) { + // Arrange + var mockTime = DateTime(2020, 1, 1, 12, 0); + var mockTimeProvider = MockTimeProvider(mockTime); + var cronExpression = CronExpression.parse('* * * * * *'); + var scheduler = CronScheduler( + cronExpression, + timeProvider: mockTimeProvider, + ); + var executionCount = 0; + + scheduler.start(() => executionCount++); + + // Act: Simulate 2 seconds passing + async.elapse(Duration(seconds: 2)); + + // Assert: Task should have executed at least once + expect(executionCount, greaterThanOrEqualTo(1)); + + // Cleanup + scheduler.stop(); + }); + }); + }); +}