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 @@
+
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)''',
+ ),
+ );
+ });
+ });
+ });
+}