diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..50a4c7b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,29 @@ +--- +name: Bug Report +about: Create a report to help us improve +title: "fix: " +labels: bug +--- + +**Description** + +A clear and concise description of what the bug is. + +**Steps To Reproduce** + +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected Behavior** + +A clear and concise description of what you expected to happen. + +**Screenshots** + +If applicable, add screenshots to help explain your problem. + +**Additional Context** + +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/build.md b/.github/ISSUE_TEMPLATE/build.md new file mode 100644 index 0000000..0cf8e62 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/build.md @@ -0,0 +1,14 @@ +--- +name: Build System +about: Changes that affect the build system or external dependencies +title: "build: " +labels: build +--- + +**Description** + +Describe what changes need to be done to the build system and why. + +**Requirements** + +- [ ] The build system is passing diff --git a/.github/ISSUE_TEMPLATE/chore.md b/.github/ISSUE_TEMPLATE/chore.md new file mode 100644 index 0000000..498ebfd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/chore.md @@ -0,0 +1,14 @@ +--- +name: Chore +about: Other changes that don't modify src or test files +title: "chore: " +labels: chore +--- + +**Description** + +Clearly describe what change is needed and why. If this changes code then please use another issue type. + +**Requirements** + +- [ ] No functional changes to the code diff --git a/.github/ISSUE_TEMPLATE/ci.md b/.github/ISSUE_TEMPLATE/ci.md new file mode 100644 index 0000000..fa2dd9e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/ci.md @@ -0,0 +1,14 @@ +--- +name: Continuous Integration +about: Changes to the CI configuration files and scripts +title: "ci: " +labels: ci +--- + +**Description** + +Describe what changes need to be done to the ci/cd system and why. + +**Requirements** + +- [ ] The ci system is passing diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..ec4bb38 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/documentation.md b/.github/ISSUE_TEMPLATE/documentation.md new file mode 100644 index 0000000..f494a4d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/documentation.md @@ -0,0 +1,14 @@ +--- +name: Documentation +about: Improve the documentation so all collaborators have a common understanding +title: "docs: " +labels: documentation +--- + +**Description** + +Clearly describe what documentation you are looking to add or improve. + +**Requirements** + +- [ ] Requirements go here diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..ddd2fcc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,18 @@ +--- +name: Feature Request +about: A new feature to be added to the project +title: "feat: " +labels: feature +--- + +**Description** + +Clearly describe what you are looking to add. The more context the better. + +**Requirements** + +- [ ] Checklist of requirements to be fulfilled + +**Additional Context** + +Add any other context or screenshots about the feature request go here. diff --git a/.github/ISSUE_TEMPLATE/performance.md b/.github/ISSUE_TEMPLATE/performance.md new file mode 100644 index 0000000..699b8d4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/performance.md @@ -0,0 +1,14 @@ +--- +name: Performance Update +about: A code change that improves performance +title: "perf: " +labels: performance +--- + +**Description** + +Clearly describe what code needs to be changed and what the performance impact is going to be. Bonus point's if you can tie this directly to user experience. + +**Requirements** + +- [ ] There is no drop in test coverage. diff --git a/.github/ISSUE_TEMPLATE/refactor.md b/.github/ISSUE_TEMPLATE/refactor.md new file mode 100644 index 0000000..1626c57 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/refactor.md @@ -0,0 +1,14 @@ +--- +name: Refactor +about: A code change that neither fixes a bug nor adds a feature +title: "refactor: " +labels: refactor +--- + +**Description** + +Clearly describe what needs to be refactored and why. Please provide links to related issues (bugs or upcoming features) in order to help prioritize. + +**Requirements** + +- [ ] There is no drop in test coverage. diff --git a/.github/ISSUE_TEMPLATE/revert.md b/.github/ISSUE_TEMPLATE/revert.md new file mode 100644 index 0000000..9d121dc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/revert.md @@ -0,0 +1,16 @@ +--- +name: Revert Commit +about: Reverts a previous commit +title: "revert: " +labels: revert +--- + +**Description** + +Provide a link to a PR/Commit that you are looking to revert and why. + +**Requirements** + +- [ ] Change has been reverted +- [ ] No change in test coverage has happened +- [ ] A new ticket is created for any follow on work that needs to happen diff --git a/.github/ISSUE_TEMPLATE/style.md b/.github/ISSUE_TEMPLATE/style.md new file mode 100644 index 0000000..02244a7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/style.md @@ -0,0 +1,14 @@ +--- +name: Style Changes +about: Changes that do not affect the meaning of the code (white space, formatting, missing semi-colons, etc) +title: "style: " +labels: style +--- + +**Description** + +Clearly describe what you are looking to change and why. + +**Requirements** + +- [ ] There is no drop in test coverage. diff --git a/.github/ISSUE_TEMPLATE/test.md b/.github/ISSUE_TEMPLATE/test.md new file mode 100644 index 0000000..431a7ea --- /dev/null +++ b/.github/ISSUE_TEMPLATE/test.md @@ -0,0 +1,14 @@ +--- +name: Test +about: Adding missing tests or correcting existing tests +title: "test: " +labels: test +--- + +**Description** + +List out the tests that need to be added or changed. Please also include any information as to why this was not covered in the past. + +**Requirements** + +- [ ] There is no drop in test coverage. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..1169936 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,27 @@ + + +## Status + +**READY/IN DEVELOPMENT/HOLD** + +## Description + + + +## Type of Change + + + +- [ ] โœจ New feature (non-breaking change which adds functionality) +- [ ] ๐Ÿ› ๏ธ Bug fix (non-breaking change which fixes an issue) +- [ ] โŒ Breaking change (fix or feature that would cause existing functionality to change) +- [ ] ๐Ÿงน Code refactor +- [ ] โœ… Build configuration change +- [ ] ๐Ÿ“ Documentation +- [ ] ๐Ÿ—‘๏ธ Chore diff --git a/.github/cspell.json b/.github/cspell.json new file mode 100644 index 0000000..5f0a476 --- /dev/null +++ b/.github/cspell.json @@ -0,0 +1,21 @@ +{ + "version": "0.2", + "$schema": "https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json", + "dictionaries": ["vgv_allowed", "vgv_forbidden"], + "dictionaryDefinitions": [ + { + "name": "vgv_allowed", + "path": "https://raw.githubusercontent.com/verygoodopensource/very_good_dictionaries/main/allowed.txt", + "description": "Allowed VGV Spellings" + }, + { + "name": "vgv_forbidden", + "path": "https://raw.githubusercontent.com/verygoodopensource/very_good_dictionaries/main/forbidden.txt", + "description": "Forbidden VGV Spellings" + } + ], + "useGitignore": true, + "words": [ + "request_validator" + ] +} diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml new file mode 100644 index 0000000..63b035c --- /dev/null +++ b/.github/dependabot.yaml @@ -0,0 +1,11 @@ +version: 2 +enable-beta-ecosystems: true +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + - package-ecosystem: "pub" + directory: "/" + schedule: + interval: "daily" diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml new file mode 100644 index 0000000..c7146c3 --- /dev/null +++ b/.github/workflows/main.yaml @@ -0,0 +1,27 @@ +name: ci + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + semantic_pull_request: + uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/semantic_pull_request.yml@v1 + + spell-check: + uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/spell_check.yml@v1 + with: + includes: "**/*.md" + modified_files_only: false + + build: + uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/dart_package.yml@v1 + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..57f0116 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +# See https://www.dartlang.org/guides/libraries/private-files + +# Files and directories created by pub +.dart_tool/ +.packages +build/ +pubspec.lock +coverage/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..7ac98af --- /dev/null +++ b/README.md @@ -0,0 +1,22 @@ +# Request Validator + +[![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link] +[![Powered by Mason](https://img.shields.io/endpoint?url=https%3A%2F%2Ftinyurl.com%2Fmason-badge)](https://github.com/felangel/mason) +[![License: MIT][license_badge]][license_link] + +A middleware to validate request body before route handler, focused with Dart Frog. + +[dart_install_link]: https://dart.dev/get-dart +[github_actions_link]: https://docs.github.com/en/actions/learn-github-actions +[license_badge]: https://img.shields.io/badge/license-MIT-blue.svg +[license_link]: https://opensource.org/licenses/MIT +[logo_black]: https://raw.githubusercontent.com/VGVentures/very_good_brand/main/styles/README/vgv_logo_black.png#gh-light-mode-only +[logo_white]: https://raw.githubusercontent.com/VGVentures/very_good_brand/main/styles/README/vgv_logo_white.png#gh-dark-mode-only +[mason_link]: https://github.com/felangel/mason +[very_good_analysis_badge]: https://img.shields.io/badge/style-very_good_analysis-B22C89.svg +[very_good_analysis_link]: https://pub.dev/packages/very_good_analysis +[very_good_coverage_link]: https://github.com/marketplace/actions/very-good-coverage +[very_good_ventures_link]: https://verygood.ventures +[very_good_ventures_link_light]: https://verygood.ventures#gh-light-mode-only +[very_good_ventures_link_dark]: https://verygood.ventures#gh-dark-mode-only +[very_good_workflows_link]: https://github.com/VeryGoodOpenSource/very_good_workflows diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..799268d --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1 @@ +include: package:very_good_analysis/analysis_options.5.1.0.yaml diff --git a/coverage_badge.svg b/coverage_badge.svg new file mode 100644 index 0000000..499e98c --- /dev/null +++ b/coverage_badge.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + coverage + coverage + 100% + 100% + + diff --git a/lib/request_validator.dart b/lib/request_validator.dart new file mode 100644 index 0000000..37bd92c --- /dev/null +++ b/lib/request_validator.dart @@ -0,0 +1,7 @@ +/// A middleware to validate request body before route handler, +/// focused with Dart Frog. +library request_validator; + +export 'src/request_validator.dart'; +export 'src/validation_error.dart'; +export 'src/validation_rule.dart'; diff --git a/lib/src/request_validator.dart b/lib/src/request_validator.dart new file mode 100644 index 0000000..fc20352 --- /dev/null +++ b/lib/src/request_validator.dart @@ -0,0 +1,146 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:dart_frog/dart_frog.dart'; +import 'package:meta/meta.dart'; +import 'package:request_validator/request_validator.dart'; + +/// The type definition for a [Map] +typedef JsonMap = Map; + +/// {@template request_validator} +/// A [RequestValidator] represents a component responsible for validating +/// incoming HTTP requests. It defines the rules and behaviour for ensuring +/// that requests adhere to expected format and data structure. +/// +/// [RequestValidator] should be extended to define custom [RequestValidator] +/// instances. For example, +/// +/// ```dart +/// class PersonValidator extends RequestValidator { +/// PersonValidator() : super(allowedMethods: [HttpMethod.post]); +/// +/// @override +/// FutureOr onError(List errors) { +/// return Response.json( +/// statusCode: HttpStatus.badRequest, +/// body: errors.toMapArray() +/// ); +/// } +/// +/// @override +/// List validationRules() => [ +/// ValidationRule.body('name', (value) => value is String), +/// ValidationRule.body('age', (value) => value is int && value > 0), +/// ]; +/// } +/// ``` +/// {@endtemplate} +@immutable +abstract class RequestValidator { + /// {@macro request_validator} + const RequestValidator({required this.allowedMethods}); + + /// List of [HttpMethod]s where the validation will be performed. + /// + /// This defines for which HTTP methods (e.g., POST, PUT) the validations + /// specified in this validator will be applied. If a request with a different + /// method is received, the validation will be skipped. + final List allowedMethods; + + /// List of [ValidationRule]s to be performed to validate the incoming HTTP + /// request body. + /// + /// This method should return a list of `ValidationRule` objects that define + /// specific validation logic for different fields within the request body. + /// The framework will iterate through these rules and perform the + /// validations on the corresponding fields. + List validationRules(); + + /// [Response] object to be sent to the client when the validation + /// fails due to [ValidationRule]s. + /// + /// The `errors` object within the response should contain details + /// about the validation failures, while a successful validation would result + /// in an empty error list. + FutureOr onError(List errors); + + /// Provides a default error message when a validation rule fails for a field. + String defaultErrorMessage(String fieldName) { + return '''The field '$fieldName' is invalid. Please check the validation rules for this field.'''; + } + + /// Private method that iterates through validation rules to build + /// a list of [ValidationError] objects when [ValidationRule] fails. + List _validateRulesAndFindErrors(JsonMap requestBody) { + final errors = []; + + // Iterate through the validation rules + for (final rule in validationRules()) { + // Check if the field exists in the Request Body + final doesFieldExist = requestBody.containsKey(rule.fieldName); + // Check if the field is optional, then continue if field doesn't exist + if (!doesFieldExist && rule.optional) continue; + // If field doesn not exist but field is also not optional, create error + if (!doesFieldExist && !rule.optional) { + errors.add(_buildValidationError(rule, null)); + continue; + } + + // Find the value from request body and validate against validator + final value = requestBody[rule.fieldName]; + final isValueValidated = rule.validator(value); + // Create a validation error if the validation is unsuccessful + if (!isValueValidated) { + errors.add(_buildValidationError(rule, value)); + } + } + + return errors; + } + + /// Returns a [ValidationError] object from [ValidationRule]. + ValidationError _buildValidationError(ValidationRule rule, dynamic value) { + return ValidationError( + fieldName: rule.fieldName, + value: value, + errorMessage: rule.message ?? defaultErrorMessage(rule.fieldName), + ); + } + + @override + bool operator ==(covariant RequestValidator other) { + if (identical(this, other)) return true; + final listEquals = const DeepCollectionEquality().equals; + return listEquals(other.allowedMethods, allowedMethods); + } + + @override + int get hashCode => Object.hashAll([allowedMethods]); +} + +/// Extention on [RequestValidator] +extension RequestValidatorX on RequestValidator { + /// Converts the [RequestValidator] into a middleware function. + Middleware serveAsMiddleware() { + return (handler) { + return (context) async { + // Find the HTTP Method, and check if it's part of the allowed methods + final method = context.request.method; + if (allowedMethods.contains(method)) { + // Extract the request body object as JSON. + final requestBody = await context.request.json() as JsonMap; + // Validate against validation rules and build validation errors + final validationErrors = _validateRulesAndFindErrors(requestBody); + // If there's error, return the `onError` Response + if (validationErrors.isNotEmpty) { + return onError(validationErrors); + } + } + // Complete the route handler and return the response + final response = await handler(context); + return response; + }; + }; + } +} diff --git a/lib/src/validation_error.dart b/lib/src/validation_error.dart new file mode 100644 index 0000000..7c00a84 --- /dev/null +++ b/lib/src/validation_error.dart @@ -0,0 +1,57 @@ +import 'package:meta/meta.dart'; +import 'package:request_validator/request_validator.dart'; + +/// {@template validation_error} +/// A [ValidationError] represents an error caused from a request +/// field validation. +/// +/// It contains information about where the field is located, +/// what the value is, and associated error message, if any. +/// {@endtemplate} +@immutable +class ValidationError { + /// {@macro validation_error} + const ValidationError({ + required this.fieldName, + required this.value, + this.errorMessage, + }); + + /// The name of the field + final String fieldName; + + /// The value of the field + final dynamic value; + + /// The error message to be used when the field validation fails. + final String? errorMessage; + + @override + bool operator ==(covariant ValidationError other) { + if (identical(this, other)) return true; + + return other.fieldName == fieldName && + other.value == value && + other.errorMessage == errorMessage; + } + + @override + int get hashCode { + return Object.hashAll([fieldName, value, errorMessage]); + } + + @override + String toString() { + return errorMessage == null + ? '''ValidationError(fieldName: $fieldName, value: $value)''' + : '''ValidationError(fieldName: $fieldName, value: $value, errorMessage: $errorMessage)'''; + } +} + +/// Extension on list of [ValidationError] +extension ValidationErrorX on List { + /// Converts list of [ValidationError] to [JsonMap]. + JsonMap toMapArray() { + return {'errors': map((error) => error.errorMessage).toList()}; + } +} diff --git a/lib/src/validation_rule.dart b/lib/src/validation_rule.dart new file mode 100644 index 0000000..2391161 --- /dev/null +++ b/lib/src/validation_rule.dart @@ -0,0 +1,82 @@ +import 'package:meta/meta.dart'; + +/// {@template validation_rule} +/// A [ValidationRule] represents the validation logic of a single +/// field from the request. +/// +/// It contains information about where the field is located, +/// what the validation logic is, and associated error message. +/// {@endtemplate} +@immutable +class ValidationRule { + const ValidationRule._({ + required this.location, + required this.fieldName, + required this.validator, + required this.optional, + this.message, + }); + + /// Constructor which creates a [ValidationRule] for request body fields. + const ValidationRule.body( + String fieldName, + bool Function(dynamic) validator, { + bool? optional, + String? message, + }) : this._( + location: 'body', + fieldName: fieldName, + validator: validator, + optional: optional ?? false, + message: message, + ); + + /// The location from where the field is picked. This parameter + /// currently only extracts fields from request body to validate. Other + /// locations, e.g., params, query, form-data will be introduced later. + /// + /// TODO(thecodexhub): Add support for other field location. + final String location; + + /// The name of the field. + final String fieldName; + + /// Validation function that validates the field. + /// It should return true when the validation is successful, otherwise false. + final bool Function(dynamic) validator; + + /// Whether or not the field is optional. + /// + /// If set to true, it first checks whether the field was available of the + /// `location`, and if it exists, it'll perform the validation logic. + final bool optional; + + /// Message to be set when the field validation fails. + /// + /// If the message is null, a default message will be set + /// at the time of validation failure. + final String? message; + + @override + bool operator ==(covariant ValidationRule other) { + if (identical(this, other)) return true; + + return other.location == location && + other.fieldName == fieldName && + other.validator == validator && + other.optional == optional && + other.message == message; + } + + @override + int get hashCode { + return Object.hashAll([location, fieldName, validator, optional, message]); + } + + @override + String toString() { + return location == 'body' + ? '''ValidationRule.body($fieldName, $validator, optional: $optional, message: $message)''' + : '''ValidationRule(location: $location, fieldName: $fieldName, validator: $validator, optional: $optional, message: $message)'''; + } +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..1d96e13 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,18 @@ +name: request_validator +description: A middleware to validate request body before route handler, focused with Dart Frog. +version: 0.1.0 +repository: https://github.com/thecodexhub/request-validator +issue_tracker: https://github.com/thecodexhub/request-validator/issues + +environment: + sdk: ">=3.0.0 <4.0.0" + +dependencies: + collection: ^1.18.0 + dart_frog: ^1.1.0 + meta: ^1.15.0 + +dev_dependencies: + mocktail: ^1.0.3 + test: ^1.25.2 + very_good_analysis: ^5.1.0 diff --git a/test/helpers/helpers.dart b/test/helpers/helpers.dart new file mode 100644 index 0000000..7750222 --- /dev/null +++ b/test/helpers/helpers.dart @@ -0,0 +1 @@ +export 'person_validator.dart'; diff --git a/test/helpers/person_validator.dart b/test/helpers/person_validator.dart new file mode 100644 index 0000000..cd01045 --- /dev/null +++ b/test/helpers/person_validator.dart @@ -0,0 +1,23 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:dart_frog/dart_frog.dart'; +import 'package:request_validator/request_validator.dart'; + +class PersonValidator extends RequestValidator { + PersonValidator() : super(allowedMethods: [HttpMethod.post]); + + @override + FutureOr onError(List errors) { + return Response.json( + statusCode: HttpStatus.badRequest, + body: errors.toMapArray(), + ); + } + + @override + List validationRules() => [ + ValidationRule.body('name', (value) => value is String), + ValidationRule.body('age', (value) => value is int && value > 0), + ]; +} diff --git a/test/request_validator_test.dart b/test/request_validator_test.dart new file mode 100644 index 0000000..87a57c7 --- /dev/null +++ b/test/request_validator_test.dart @@ -0,0 +1,123 @@ +// ignore_for_file: prefer_const_constructors +import 'dart:async'; +import 'dart:io'; + +import 'package:dart_frog/dart_frog.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:request_validator/request_validator.dart'; +import 'package:test/test.dart'; + +import 'helpers/helpers.dart'; + +class _MockRequestContext extends Mock implements RequestContext {} + +class _MockRequest extends Mock implements Request {} + +void main() { + group('RequestValidator', () { + RequestValidator createSubject() => PersonValidator(); + group('constructor', () { + test('works correctly', () { + expect(createSubject, returnsNormally); + }); + + test('has correct allowed methods', () { + expect(createSubject().allowedMethods, [HttpMethod.post]); + }); + }); + + test('hashCode is correct', () { + final subject = createSubject(); + expect( + subject.hashCode, + Object.hashAll([subject.allowedMethods]), + ); + }); + + test('supports equality', () { + expect(createSubject(), equals(createSubject())); + }); + + test('defaultErrorMessage returns a formatted error message', () { + expect( + createSubject().defaultErrorMessage('name'), + '''The field 'name' is invalid. Please check the validation rules for this field.''', + ); + }); + + test('serveAsMiddleware returns a Middleware function', () { + final validator = createSubject(); + final middleware = validator.serveAsMiddleware(); + expect(middleware, isA()); + }); + + group('middleware', () { + late RequestContext context; + late Request request; + + setUp(() { + context = _MockRequestContext(); + request = _MockRequest(); + when(() => request.method).thenReturn(HttpMethod.post); + when(request.json).thenAnswer( + (_) => Future.value({'age': 24}), + ); + when(() => context.request).thenReturn(request); + }); + + test('returns 400 with correct body when field does not exist', () async { + final middleware = createSubject().serveAsMiddleware(); + final response = await middleware((_) async => Response())(context); + expect( + response, + isA().having( + (r) => r.statusCode, + 'statusCode', + HttpStatus.badRequest, + ), + ); + expect( + await response.body(), + '''{"errors":["The field 'name' is invalid. Please check the validation rules for this field."]}''', + ); + }); + + test('returns 400 with correct body when validation fails', () async { + when(request.json).thenAnswer( + (_) => Future.value({'name': 123, 'age': 24}), + ); + final middleware = createSubject().serveAsMiddleware(); + final response = await middleware((_) async => Response())(context); + expect( + response, + isA().having( + (r) => r.statusCode, + 'statusCode', + HttpStatus.badRequest, + ), + ); + expect( + await response.body(), + '''{"errors":["The field 'name' is invalid. Please check the validation rules for this field."]}''', + ); + }); + + test('returns 200 with correct body when validation succeeds', () async { + when(request.json).thenAnswer( + (_) => Future.value({'name': 'Bob', 'age': 24}), + ); + final middleware = createSubject().serveAsMiddleware(); + final response = await middleware((_) async => Response())(context); + expect( + response, + isA().having( + (r) => r.statusCode, + 'statusCode', + HttpStatus.ok, + ), + ); + expect(await response.body(), ''); + }); + }); + }); +} diff --git a/test/validation_error_test.dart b/test/validation_error_test.dart new file mode 100644 index 0000000..8e5ba56 --- /dev/null +++ b/test/validation_error_test.dart @@ -0,0 +1,63 @@ +import 'package:request_validator/request_validator.dart'; +import 'package:test/test.dart'; + +void main() { + group('ValidationError', () { + ValidationError createSubject({ + String? fieldName, + dynamic value, + String? errorMessage, + }) { + return ValidationError( + fieldName: fieldName ?? 'name', + value: value ?? 'test', + errorMessage: errorMessage, + ); + } + + group('contructor', () { + test('works perfectly', () { + expect(createSubject, returnsNormally); + }); + }); + + test('hashCode is correct', () { + final subject = createSubject(); + expect( + subject.hashCode, + Object.hashAll( + [ + subject.fieldName, + subject.value, + subject.errorMessage, + ], + ), + ); + }); + + test('supports equality', () { + expect( + createSubject(), + equals(createSubject()), + ); + }); + + group('toString', () { + test('works correctly when there is a errorMessage', () { + expect( + createSubject(errorMessage: 'test').toString(), + equals( + '''ValidationError(fieldName: name, value: test, errorMessage: test)''', + ), + ); + }); + + test('works correctly when there is no errorMessage', () { + expect( + createSubject().toString(), + equals('ValidationError(fieldName: name, value: test)'), + ); + }); + }); + }); +} diff --git a/test/validation_rule_test.dart b/test/validation_rule_test.dart new file mode 100644 index 0000000..90c72d6 --- /dev/null +++ b/test/validation_rule_test.dart @@ -0,0 +1,61 @@ +import 'package:request_validator/request_validator.dart'; +import 'package:test/test.dart'; + +void main() { + group('ValidationRule', () { + ValidationRule createSubject({ + String? fieldName, + bool Function(dynamic)? validator, + bool? optional, + String? message, + }) { + return ValidationRule.body( + fieldName ?? 'name', + validator ?? (value) => value is Object, + optional: optional ?? false, + message: message ?? 'Name is required', + ); + } + + group('contructor', () { + test('works perfectly', () { + expect(createSubject, returnsNormally); + }); + }); + + test('hashCode is correct', () { + final subject = createSubject(); + expect( + subject.hashCode, + Object.hashAll( + [ + subject.location, + subject.fieldName, + subject.validator, + subject.optional, + subject.message, + ], + ), + ); + }); + + test('supports equality', () { + bool mockValidator(dynamic value) => value is Object; + expect( + createSubject(validator: mockValidator), + equals(createSubject(validator: mockValidator)), + ); + }); + + group('toString', () { + test('works correctly', () { + expect( + createSubject().toString(), + equals( + '''ValidationRule.body(name, Closure: (dynamic) => bool, optional: false, message: Name is required)''', + ), + ); + }); + }); + }); +}