From 18fa8e729f7e8a3b4b9cfcdc2f417f73d5fd0142 Mon Sep 17 00:00:00 2001 From: Alan Barber Date: Sun, 28 Dec 2025 15:18:32 -0500 Subject: [PATCH 1/2] First build out of ResultR.Valdiation companion library --- .../{build.yml => release-resultr.yml} | 0 .github/workflows/release-validation.yml | 59 +++ README.md | 48 ++ docs/wiki/Home.md | 6 + docs/wiki/ResultR.Validation.md | 480 ++++++++++++++++++ .../ComparisonValidationTests.cs | 270 ++++++++++ .../CustomValidationTests.cs | 69 +++ .../EqualityValidationTests.cs | 128 +++++ .../LengthValidationTests.cs | 160 ++++++ .../MultipleRulesTests.cs | 137 +++++ .../NotEmptyValidationTests.cs | 112 ++++ .../NotNullValidationTests.cs | 63 +++ .../RegexValidationTests.cs | 151 ++++++ .../ResultR.Validation.Tests.csproj | 28 + .../ValidatorTests.cs | 30 ++ src/ResultR.Validation/ExpressionUtilities.cs | 23 + src/ResultR.Validation/README.md | 146 ++++++ .../ResultR.Validation.csproj | 36 ++ src/ResultR.Validation/RuleBuilder.cs | 41 ++ .../RuleBuilderExtensions.cs | 426 ++++++++++++++++ src/ResultR.Validation/ValidationBuilder.cs | 50 ++ src/ResultR.Validation/ValidationError.cs | 8 + .../ValidationMetadataKeys.cs | 12 + src/ResultR.Validation/Validator.cs | 23 + src/ResultR.slnx | 2 + src/ResultR/ResultR.csproj | 5 +- 26 files changed, 2511 insertions(+), 2 deletions(-) rename .github/workflows/{build.yml => release-resultr.yml} (100%) create mode 100644 .github/workflows/release-validation.yml create mode 100644 docs/wiki/ResultR.Validation.md create mode 100644 src/ResultR.Validation.Tests/ComparisonValidationTests.cs create mode 100644 src/ResultR.Validation.Tests/CustomValidationTests.cs create mode 100644 src/ResultR.Validation.Tests/EqualityValidationTests.cs create mode 100644 src/ResultR.Validation.Tests/LengthValidationTests.cs create mode 100644 src/ResultR.Validation.Tests/MultipleRulesTests.cs create mode 100644 src/ResultR.Validation.Tests/NotEmptyValidationTests.cs create mode 100644 src/ResultR.Validation.Tests/NotNullValidationTests.cs create mode 100644 src/ResultR.Validation.Tests/RegexValidationTests.cs create mode 100644 src/ResultR.Validation.Tests/ResultR.Validation.Tests.csproj create mode 100644 src/ResultR.Validation.Tests/ValidatorTests.cs create mode 100644 src/ResultR.Validation/ExpressionUtilities.cs create mode 100644 src/ResultR.Validation/README.md create mode 100644 src/ResultR.Validation/ResultR.Validation.csproj create mode 100644 src/ResultR.Validation/RuleBuilder.cs create mode 100644 src/ResultR.Validation/RuleBuilderExtensions.cs create mode 100644 src/ResultR.Validation/ValidationBuilder.cs create mode 100644 src/ResultR.Validation/ValidationError.cs create mode 100644 src/ResultR.Validation/ValidationMetadataKeys.cs create mode 100644 src/ResultR.Validation/Validator.cs diff --git a/.github/workflows/build.yml b/.github/workflows/release-resultr.yml similarity index 100% rename from .github/workflows/build.yml rename to .github/workflows/release-resultr.yml diff --git a/.github/workflows/release-validation.yml b/.github/workflows/release-validation.yml new file mode 100644 index 0000000..08047fb --- /dev/null +++ b/.github/workflows/release-validation.yml @@ -0,0 +1,59 @@ +name: Release ResultR.Validation + +on: + push: + tags: + - 'validation-v*' + +env: + DOTNET_VERSION: '10.0.x' + +permissions: + contents: write + +jobs: + release-validation: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Restore dependencies + run: dotnet restore ResultR.slnx + working-directory: src + + - name: Build + run: dotnet build ResultR.slnx --configuration Release --no-restore + working-directory: src + + - name: Test + run: dotnet test ResultR.slnx --configuration Release --no-build --verbosity normal + working-directory: src + + - name: Pack ResultR.Validation + run: | + VERSION=${GITHUB_REF#refs/tags/validation-v} + dotnet pack ResultR.Validation.csproj --configuration Release --no-build -p:PackageVersion=$VERSION -o ./nupkg + working-directory: src/ResultR.Validation + + - name: Publish ResultR.Validation to NuGet + run: dotnet nuget push ./nupkg/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate + working-directory: src/ResultR.Validation + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: | + src/ResultR.Validation/nupkg/*.nupkg + src/ResultR.Validation/nupkg/*.snupkg + generate_release_notes: true + draft: false + prerelease: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index a4fcf5a..e15f1d6 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,16 @@ ResultR prioritizes: dotnet add package ResultR ``` +### Optional: ResultR.Validation + +For inline validation with a fluent API: + +```bash +dotnet add package ResultR.Validation +``` + +**[β†’ Learn more about ResultR.Validation](https://github.com/AlanBarber/ResultR/wiki/ResultR.Validation)** + ## πŸš€ Quick Start ### 1. Define a Request @@ -199,6 +209,44 @@ public class DeleteUserHandler : IRequestHandler ## πŸ”§ Advanced Features +### ResultR.Validation (Optional Package) + +Add fluent inline validation to your handlers without separate validator classes: + +```csharp +dotnet add package ResultR.Validation +``` + +```csharp +using ResultR.Validation; + +public class CreateUserHandler : IRequestHandler +{ + public ValueTask ValidateAsync(CreateUserRequest request) + { + return Validator.For(request) + .RuleFor(x => x.Email) + .NotEmpty("Email is required") + .EmailAddress("Invalid email format") + .RuleFor(x => x.Name) + .NotEmpty("Name is required") + .MinLength(2, "Name must be at least 2 characters") + .RuleFor(x => x.Age) + .GreaterThan(0, "Age must be positive") + .ToResult(); + } + + public async ValueTask> HandleAsync(CreateUserRequest request, CancellationToken ct) + { + var user = new User(request.Email, request.Name, request.Age); + await _repository.AddAsync(user, ct); + return Result.Success(user); + } +} +``` + +**[β†’ Full ResultR.Validation documentation](https://github.com/AlanBarber/ResultR/wiki/ResultR.Validation)** + ### Metadata Support ```csharp diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index e4f2dee..8357b07 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -65,6 +65,8 @@ else ## Documentation +### Core Documentation + - [Getting Started](Getting-Started) - Installation and basic setup - [Requests and Handlers](Requests-and-Handlers) - Creating requests and handlers - [The Result Type](The-Result-Type) - Working with success and failure states @@ -73,6 +75,10 @@ else - [Error Handling](Error-Handling) - Exception handling and failure patterns - [Best Practices](Best-Practices) - Recommended patterns and conventions +### Optional Packages + +- [ResultR.Validation](ResultR.Validation) - Inline validation with fluent API + ## Requirements - .NET 10.0 or later diff --git a/docs/wiki/ResultR.Validation.md b/docs/wiki/ResultR.Validation.md new file mode 100644 index 0000000..71cdda1 --- /dev/null +++ b/docs/wiki/ResultR.Validation.md @@ -0,0 +1,480 @@ +# ResultR.Validation + +**ResultR.Validation** is an optional companion package that provides a lightweight, inline validation framework designed specifically for ResultR's `ValidateAsync()` pipeline hook. + +## Overview + +Unlike FluentValidation which requires separate validator classes and DI registration, ResultR.Validation allows you to define validation rules **directly inside your `ValidateAsync()` method** using a fluent API. It integrates seamlessly with ResultR's `Result` type, automatically returning `Result.Success()` or `Result.Failure()` with aggregated validation errors. + +## Installation + +```bash +dotnet add package ResultR.Validation +``` + +## Key Benefits + +- βœ… **Zero ceremony** - No external validator classes, no DI registration for validators +- βœ… **Inline validation** - Define rules directly in `ValidateAsync()` using a fluent API +- βœ… **Seamless integration** - Works with both `IRequestHandler` and `IRequestHandler` +- βœ… **Automatic result conversion** - Returns `Result.Success()` when all validations pass, or `Result.Failure()` with aggregated errors +- βœ… **Comprehensive built-in rules** - String, numeric, collection, and custom validations out of the box + +## Quick Start + +```csharp +using ResultR; +using ResultR.Validation; + +public record CreateUserRequest(string Email, string Name, int Age) : IRequest; + +public class CreateUserHandler : IRequestHandler +{ + public ValueTask ValidateAsync(CreateUserRequest request) + { + return Validator.For(request) + .RuleFor(x => x.Email) + .NotEmpty("Email is required") + .EmailAddress("Invalid email format") + .RuleFor(x => x.Name) + .NotEmpty("Name is required") + .MinLength(2, "Name must be at least 2 characters") + .MaxLength(100, "Name cannot exceed 100 characters") + .RuleFor(x => x.Age) + .GreaterThan(0, "Age must be positive") + .LessThanOrEqualTo(150, "Age must be realistic") + .ToResult(); + } + + public async ValueTask> HandleAsync(CreateUserRequest request, CancellationToken ct) + { + // This only runs if validation passes + var user = new User(request.Email, request.Name, request.Age); + await _repository.AddAsync(user, ct); + return Result.Success(user); + } +} +``` + +## Built-in Validation Rules + +### String Validations + +#### NotEmpty() +Validates that a string is not null, empty, or whitespace. + +```csharp +.RuleFor(x => x.Name) + .NotEmpty("Name is required") +``` + +#### MinLength(int minLength) +Validates that a string has a minimum length. + +```csharp +.RuleFor(x => x.Password) + .MinLength(8, "Password must be at least 8 characters") +``` + +#### MaxLength(int maxLength) +Validates that a string does not exceed a maximum length. + +```csharp +.RuleFor(x => x.Username) + .MaxLength(50, "Username cannot exceed 50 characters") +``` + +#### Length(int minLength, int maxLength) +Validates that a string length is within a specified range. + +```csharp +.RuleFor(x => x.ZipCode) + .Length(5, 10, "Zip code must be between 5 and 10 characters") +``` + +#### Matches(string pattern, RegexOptions options = RegexOptions.None) +Validates that a string matches a regular expression pattern. + +```csharp +.RuleFor(x => x.PhoneNumber) + .Matches(@"^\d{3}-\d{3}-\d{4}$", "Phone number must be in format: 123-456-7890") +``` + +#### EmailAddress() +Validates that a string is a valid email address format. + +```csharp +.RuleFor(x => x.Email) + .EmailAddress("Invalid email format") +``` + +### Numeric Validations + +#### GreaterThan(T comparisonValue) +Validates that a value is greater than the specified comparison value. + +```csharp +.RuleFor(x => x.Age) + .GreaterThan(0, "Age must be positive") +``` + +#### GreaterThanOrEqualTo(T comparisonValue) +Validates that a value is greater than or equal to the specified comparison value. + +```csharp +.RuleFor(x => x.Quantity) + .GreaterThanOrEqualTo(1, "Quantity must be at least 1") +``` + +#### LessThan(T comparisonValue) +Validates that a value is less than the specified comparison value. + +```csharp +.RuleFor(x => x.Discount) + .LessThan(100, "Discount must be less than 100%") +``` + +#### LessThanOrEqualTo(T comparisonValue) +Validates that a value is less than or equal to the specified comparison value. + +```csharp +.RuleFor(x => x.Age) + .LessThanOrEqualTo(150, "Age must be realistic") +``` + +#### Between(T minValue, T maxValue) +Validates that a value is within the specified range (inclusive). + +```csharp +.RuleFor(x => x.Rating) + .Between(1, 5, "Rating must be between 1 and 5") +``` + +### Collection Validations + +#### NotEmpty() +Validates that a collection is not null or empty. + +```csharp +.RuleFor(x => x.Items) + .NotEmpty("Order must contain at least one item") +``` + +### General Validations + +#### NotNull() +Validates that a value is not null. + +```csharp +.RuleFor(x => x.Address) + .NotNull("Address is required") +``` + +#### Equal(T comparisonValue) +Validates that a value equals the specified comparison value. + +```csharp +.RuleFor(x => x.ConfirmPassword) + .Equal(request.Password, "Passwords must match") +``` + +#### NotEqual(T disallowedValue) +Validates that a value does not equal the specified disallowed value. + +```csharp +.RuleFor(x => x.NewEmail) + .NotEqual(currentEmail, "New email must be different from current email") +``` + +#### Must(Func predicate, string message) +Validates that a value satisfies a custom predicate. + +```csharp +.RuleFor(x => x.Email) + .Must(email => email.EndsWith("@company.com"), "Must use company email") +``` + +## Custom Validation Rules + +Use the `Must()` method to implement custom validation logic: + +```csharp +public ValueTask ValidateAsync(CreateUserRequest request) +{ + return Validator.For(request) + .RuleFor(x => x.Email) + .NotEmpty("Email is required") + .Must(email => email.EndsWith("@company.com"), "Must use company email") + .Must(email => !_blacklist.Contains(email), "Email is blacklisted") + .RuleFor(x => x.Name) + .Must(name => !name.Contains("admin", StringComparison.OrdinalIgnoreCase), + "Name cannot contain 'admin'") + .Must(name => !_profanityFilter.ContainsProfanity(name), + "Name contains inappropriate language") + .ToResult(); +} +``` + +## Accessing Validation Errors + +When validation fails, errors are stored in the `Result` metadata under the `ValidationErrors` key: + +```csharp +var result = await _dispatcher.Dispatch(request); + +if (result.IsFailure) +{ + var errors = result.GetMetadataValueOrDefault>( + ValidationMetadataKeys.ValidationErrors); + + if (errors is not null) + { + foreach (var error in errors) + { + Console.WriteLine($"{error.PropertyName}: {error.ErrorMessage}"); + } + } +} + +// Example output: +// Email: Email is required +// Name: Name must be at least 2 characters +// Age: Age must be positive +``` + +### ValidationError Record + +```csharp +public record ValidationError(string PropertyName, string ErrorMessage); +``` + +## How It Works + +1. **`Validator.For(request)`** creates a `ValidationBuilder` for the request +2. **`RuleFor(x => x.Property)`** selects a property and returns a `RuleBuilder` +3. **Validation methods** (e.g., `NotEmpty()`, `MinLength()`) add rules to an internal list +4. **`ToResult()`** executes all rules and returns: + - `Result.Success()` if all rules pass + - `Result.Failure("Validation failed")` with errors in metadata if any rule fails + +## Chaining Multiple Properties + +You can validate multiple properties in a single fluent chain: + +```csharp +return Validator.For(request) + .RuleFor(x => x.Email) + .NotEmpty("Email is required") + .EmailAddress("Invalid email format") + .RuleFor(x => x.Name) + .NotEmpty("Name is required") + .MinLength(2, "Name too short") + .RuleFor(x => x.Age) + .GreaterThan(0, "Age must be positive") + .ToResult(); +``` + +## Default Error Messages + +Most validation rules provide default error messages if you don't specify a custom message: + +```csharp +.RuleFor(x => x.Name) + .NotEmpty() // Default: "Name is required." + .MinLength(2) // Default: "Name must be at least 2 characters." +``` + +Custom messages override the defaults: + +```csharp +.RuleFor(x => x.Name) + .NotEmpty("Please enter your name") + .MinLength(2, "Your name is too short") +``` + +## Integration with ASP.NET Core + +### Returning Validation Errors in API Responses + +```csharp +[HttpPost] +public async Task CreateUser(CreateUserRequest request) +{ + var result = await _dispatcher.Dispatch(request); + + if (result.IsFailure) + { + var validationErrors = result.GetMetadataValueOrDefault>( + ValidationMetadataKeys.ValidationErrors); + + if (validationErrors is not null) + { + var errors = validationErrors.ToDictionary( + e => e.PropertyName, + e => e.ErrorMessage); + + return BadRequest(new { errors }); + } + + return BadRequest(new { error = result.Error }); + } + + return Ok(result.Value); +} +``` + +### ModelState Integration + +```csharp +if (result.IsFailure) +{ + var validationErrors = result.GetMetadataValueOrDefault>( + ValidationMetadataKeys.ValidationErrors); + + if (validationErrors is not null) + { + foreach (var error in validationErrors) + { + ModelState.AddModelError(error.PropertyName, error.ErrorMessage); + } + return ValidationProblem(ModelState); + } +} +``` + +## Comparison with FluentValidation + +| Feature | ResultR.Validation | FluentValidation | +|---------|-------------------|------------------| +| Validator classes | ❌ Not required | βœ… Required | +| DI registration | ❌ Not required | βœ… Required | +| Inline validation | βœ… Yes | ❌ No | +| Location | Inside handler | Separate class | +| Setup complexity | Minimal | Higher | +| Flexibility | Good for simple cases | Excellent for complex scenarios | +| Reusability | Per-handler | Across application | + +**When to use ResultR.Validation:** +- Simple to moderate validation requirements +- You prefer keeping validation close to handler logic +- You want minimal setup and configuration +- You're already using ResultR + +**When to use FluentValidation:** +- Complex validation scenarios with many rules +- You need to reuse validators across multiple handlers +- You require advanced features (custom validators, rule sets, etc.) +- You need validation outside of the request/handler pattern + +## Best Practices + +### 1. Keep Validation Rules Simple + +```csharp +// Good: Simple, readable rules +.RuleFor(x => x.Email) + .NotEmpty("Email is required") + .EmailAddress("Invalid email format") + +// Avoid: Complex logic in Must() +.RuleFor(x => x.Email) + .Must(email => { + var domain = email.Split('@')[1]; + return _allowedDomains.Contains(domain) && + !_blacklist.Contains(email) && + email.Length < 100; + }, "Email validation failed") +``` + +### 2. Use Descriptive Error Messages + +```csharp +// Good: Clear, actionable message +.RuleFor(x => x.Password) + .MinLength(8, "Password must be at least 8 characters long") + +// Avoid: Vague message +.RuleFor(x => x.Password) + .MinLength(8, "Invalid password") +``` + +### 3. Validate Business Rules in HandleAsync + +```csharp +// ValidateAsync: Input validation only +public ValueTask ValidateAsync(CreateUserRequest request) +{ + return Validator.For(request) + .RuleFor(x => x.Email) + .NotEmpty("Email is required") + .EmailAddress("Invalid email format") + .ToResult(); +} + +// HandleAsync: Business rule validation +public async ValueTask> HandleAsync(CreateUserRequest request, CancellationToken ct) +{ + // Check if email already exists (requires database access) + if (await _repository.EmailExistsAsync(request.Email, ct)) + return Result.Failure("Email already in use"); + + var user = new User(request.Email, request.Name); + await _repository.AddAsync(user, ct); + return Result.Success(user); +} +``` + +### 4. Chain Related Validations + +```csharp +.RuleFor(x => x.Email) + .NotEmpty("Email is required") // Check existence first + .EmailAddress("Invalid email format") // Then format + .Must(email => email.EndsWith("@company.com"), "Must use company email") // Then business rule +``` + +## Advanced Scenarios + +### Conditional Validation + +```csharp +public ValueTask ValidateAsync(UpdateUserRequest request) +{ + var validator = Validator.For(request) + .RuleFor(x => x.Name) + .NotEmpty("Name is required"); + + // Only validate email if it's being changed + if (!string.IsNullOrEmpty(request.NewEmail)) + { + validator = validator + .RuleFor(x => x.NewEmail) + .EmailAddress("Invalid email format") + .NotEqual(request.CurrentEmail, "New email must be different"); + } + + return validator.ToResult(); +} +``` + +### Cross-Property Validation + +```csharp +.RuleFor(x => x.ConfirmPassword) + .Must(confirmPassword => confirmPassword == request.Password, + "Passwords must match") +``` + +## Performance Considerations + +- Validation rules are executed synchronously +- Rules are evaluated in the order they are defined +- Validation stops at the first failure per property (short-circuits) +- All properties are validated even if one fails (to collect all errors) +- Minimal allocations - uses value types where possible + +## See Also + +- [Pipeline Hooks](Pipeline-Hooks.md) - Understanding the validation pipeline +- [Error Handling](Error-Handling.md) - Working with Result failures +- [Best Practices](Best-Practices.md) - General ResultR best practices diff --git a/src/ResultR.Validation.Tests/ComparisonValidationTests.cs b/src/ResultR.Validation.Tests/ComparisonValidationTests.cs new file mode 100644 index 0000000..1f6268b --- /dev/null +++ b/src/ResultR.Validation.Tests/ComparisonValidationTests.cs @@ -0,0 +1,270 @@ +namespace ResultR.Validation.Tests; + +public class ComparisonValidationTests +{ + [Fact] + public async Task GreaterThan_WithSmallerValue_ReturnsFailure() + { + var model = new TestModel { Age = 5 }; + + var result = await Validator.For(model) + .RuleFor(x => x.Age) + .GreaterThan(10) + .ToResult(); + + Assert.False(result.IsSuccess); + var errors = result.GetMetadataValueOrDefault>(ValidationMetadataKeys.ValidationErrors); + Assert.NotNull(errors); + Assert.Single(errors); + Assert.Contains("greater than 10", errors[0].ErrorMessage); + } + + [Fact] + public async Task GreaterThan_WithEqualValue_ReturnsFailure() + { + var model = new TestModel { Age = 10 }; + + var result = await Validator.For(model) + .RuleFor(x => x.Age) + .GreaterThan(10) + .ToResult(); + + Assert.False(result.IsSuccess); + } + + [Fact] + public async Task GreaterThan_WithLargerValue_ReturnsSuccess() + { + var model = new TestModel { Age = 15 }; + + var result = await Validator.For(model) + .RuleFor(x => x.Age) + .GreaterThan(10) + .ToResult(); + + Assert.True(result.IsSuccess); + } + + [Fact] + public async Task GreaterThan_WithNullableValue_ReturnsSuccess() + { + var model = new TestModel { NullableAge = null }; + + var result = await Validator.For(model) + .RuleFor(x => x.NullableAge) + .Must(x => x == null || x > 10, "NullableAge must be greater than 10") + .ToResult(); + + Assert.True(result.IsSuccess); + } + + [Fact] + public async Task GreaterThanOrEqualTo_WithSmallerValue_ReturnsFailure() + { + var model = new TestModel { Age = 5 }; + + var result = await Validator.For(model) + .RuleFor(x => x.Age) + .GreaterThanOrEqualTo(10) + .ToResult(); + + Assert.False(result.IsSuccess); + var errors = result.GetMetadataValueOrDefault>(ValidationMetadataKeys.ValidationErrors); + Assert.NotNull(errors); + Assert.Single(errors); + Assert.Contains("greater than or equal to 10", errors[0].ErrorMessage); + } + + [Fact] + public async Task GreaterThanOrEqualTo_WithEqualValue_ReturnsSuccess() + { + var model = new TestModel { Age = 10 }; + + var result = await Validator.For(model) + .RuleFor(x => x.Age) + .GreaterThanOrEqualTo(10) + .ToResult(); + + Assert.True(result.IsSuccess); + } + + [Fact] + public async Task GreaterThanOrEqualTo_WithLargerValue_ReturnsSuccess() + { + var model = new TestModel { Age = 15 }; + + var result = await Validator.For(model) + .RuleFor(x => x.Age) + .GreaterThanOrEqualTo(10) + .ToResult(); + + Assert.True(result.IsSuccess); + } + + [Fact] + public async Task LessThan_WithLargerValue_ReturnsFailure() + { + var model = new TestModel { Age = 15 }; + + var result = await Validator.For(model) + .RuleFor(x => x.Age) + .LessThan(10) + .ToResult(); + + Assert.False(result.IsSuccess); + var errors = result.GetMetadataValueOrDefault>(ValidationMetadataKeys.ValidationErrors); + Assert.NotNull(errors); + Assert.Single(errors); + Assert.Contains("less than 10", errors[0].ErrorMessage); + } + + [Fact] + public async Task LessThan_WithEqualValue_ReturnsFailure() + { + var model = new TestModel { Age = 10 }; + + var result = await Validator.For(model) + .RuleFor(x => x.Age) + .LessThan(10) + .ToResult(); + + Assert.False(result.IsSuccess); + } + + [Fact] + public async Task LessThan_WithSmallerValue_ReturnsSuccess() + { + var model = new TestModel { Age = 5 }; + + var result = await Validator.For(model) + .RuleFor(x => x.Age) + .LessThan(10) + .ToResult(); + + Assert.True(result.IsSuccess); + } + + [Fact] + public async Task LessThanOrEqualTo_WithLargerValue_ReturnsFailure() + { + var model = new TestModel { Age = 15 }; + + var result = await Validator.For(model) + .RuleFor(x => x.Age) + .LessThanOrEqualTo(10) + .ToResult(); + + Assert.False(result.IsSuccess); + var errors = result.GetMetadataValueOrDefault>(ValidationMetadataKeys.ValidationErrors); + Assert.NotNull(errors); + Assert.Single(errors); + Assert.Contains("less than or equal to 10", errors[0].ErrorMessage); + } + + [Fact] + public async Task LessThanOrEqualTo_WithEqualValue_ReturnsSuccess() + { + var model = new TestModel { Age = 10 }; + + var result = await Validator.For(model) + .RuleFor(x => x.Age) + .LessThanOrEqualTo(10) + .ToResult(); + + Assert.True(result.IsSuccess); + } + + [Fact] + public async Task LessThanOrEqualTo_WithSmallerValue_ReturnsSuccess() + { + var model = new TestModel { Age = 5 }; + + var result = await Validator.For(model) + .RuleFor(x => x.Age) + .LessThanOrEqualTo(10) + .ToResult(); + + Assert.True(result.IsSuccess); + } + + [Fact] + public async Task Between_WithValueBelowRange_ReturnsFailure() + { + var model = new TestModel { Age = 5 }; + + var result = await Validator.For(model) + .RuleFor(x => x.Age) + .Between(10, 20) + .ToResult(); + + Assert.False(result.IsSuccess); + var errors = result.GetMetadataValueOrDefault>(ValidationMetadataKeys.ValidationErrors); + Assert.NotNull(errors); + Assert.Single(errors); + Assert.Contains("between 10 and 20", errors[0].ErrorMessage); + } + + [Fact] + public async Task Between_WithValueAboveRange_ReturnsFailure() + { + var model = new TestModel { Age = 25 }; + + var result = await Validator.For(model) + .RuleFor(x => x.Age) + .Between(10, 20) + .ToResult(); + + Assert.False(result.IsSuccess); + } + + [Theory] + [InlineData(10)] + [InlineData(15)] + [InlineData(20)] + public async Task Between_WithValueInRange_ReturnsSuccess(int age) + { + var model = new TestModel { Age = age }; + + var result = await Validator.For(model) + .RuleFor(x => x.Age) + .Between(10, 20) + .ToResult(); + + Assert.True(result.IsSuccess); + } + + [Fact] + public async Task Between_WithNullableValue_ReturnsSuccess() + { + var model = new TestModel { NullableAge = null }; + + var result = await Validator.For(model) + .RuleFor(x => x.NullableAge) + .Must(x => x == null || (x >= 10 && x <= 20), "NullableAge must be between 10 and 20") + .ToResult(); + + Assert.True(result.IsSuccess); + } + + [Fact] + public async Task GreaterThan_WithCustomMessage_UsesCustomMessage() + { + var model = new TestModel { Age = 5 }; + + var result = await Validator.For(model) + .RuleFor(x => x.Age) + .GreaterThan(10, "Age too low") + .ToResult(); + + Assert.False(result.IsSuccess); + var errors = result.GetMetadataValueOrDefault>(ValidationMetadataKeys.ValidationErrors); + Assert.NotNull(errors); + Assert.Equal("Age too low", errors[0].ErrorMessage); + } + + private class TestModel + { + public int Age { get; set; } + public int? NullableAge { get; set; } + } +} diff --git a/src/ResultR.Validation.Tests/CustomValidationTests.cs b/src/ResultR.Validation.Tests/CustomValidationTests.cs new file mode 100644 index 0000000..ed3286c --- /dev/null +++ b/src/ResultR.Validation.Tests/CustomValidationTests.cs @@ -0,0 +1,69 @@ +namespace ResultR.Validation.Tests; + +public class CustomValidationTests +{ + [Fact] + public async Task Must_WithFailingPredicate_ReturnsFailure() + { + var model = new TestModel { Value = 5 }; + + var result = await Validator.For(model) + .RuleFor(x => x.Value) + .Must(x => x % 2 == 0, "Value must be even") + .ToResult(); + + Assert.False(result.IsSuccess); + var errors = result.GetMetadataValueOrDefault>(ValidationMetadataKeys.ValidationErrors); + Assert.NotNull(errors); + Assert.Single(errors); + Assert.Equal("Value must be even", errors[0].ErrorMessage); + } + + [Fact] + public async Task Must_WithPassingPredicate_ReturnsSuccess() + { + var model = new TestModel { Value = 4 }; + + var result = await Validator.For(model) + .RuleFor(x => x.Value) + .Must(x => x % 2 == 0, "Value must be even") + .ToResult(); + + Assert.True(result.IsSuccess); + } + + [Fact] + public async Task Must_WithComplexPredicate_Works() + { + var model = new TestModel { Name = "Test123" }; + + var result = await Validator.For(model) + .RuleFor(x => x.Name) + .Must(x => x != null && x.Any(char.IsDigit), "Name must contain at least one digit") + .ToResult(); + + Assert.True(result.IsSuccess); + } + + [Fact] + public async Task Must_WithNullValue_CanHandleNull() + { + var model = new TestModel { Name = null }; + + var result = await Validator.For(model) + .RuleFor(x => x.Name) + .Must(x => x != null && x.Length > 0, "Name is required") + .ToResult(); + + Assert.False(result.IsSuccess); + var errors = result.GetMetadataValueOrDefault>(ValidationMetadataKeys.ValidationErrors); + Assert.NotNull(errors); + Assert.Equal("Name is required", errors[0].ErrorMessage); + } + + private class TestModel + { + public int Value { get; set; } + public string? Name { get; set; } + } +} diff --git a/src/ResultR.Validation.Tests/EqualityValidationTests.cs b/src/ResultR.Validation.Tests/EqualityValidationTests.cs new file mode 100644 index 0000000..7d7c22e --- /dev/null +++ b/src/ResultR.Validation.Tests/EqualityValidationTests.cs @@ -0,0 +1,128 @@ +namespace ResultR.Validation.Tests; + +public class EqualityValidationTests +{ + [Fact] + public async Task Equal_WithDifferentValue_ReturnsFailure() + { + var model = new TestModel { Status = "Active" }; + + var result = await Validator.For(model) + .RuleFor(x => x.Status) + .Equal("Pending") + .ToResult(); + + Assert.False(result.IsSuccess); + var errors = result.GetMetadataValueOrDefault>(ValidationMetadataKeys.ValidationErrors); + Assert.NotNull(errors); + Assert.Single(errors); + Assert.Contains("must equal Pending", errors[0].ErrorMessage); + } + + [Fact] + public async Task Equal_WithSameValue_ReturnsSuccess() + { + var model = new TestModel { Status = "Active" }; + + var result = await Validator.For(model) + .RuleFor(x => x.Status) + .Equal("Active") + .ToResult(); + + Assert.True(result.IsSuccess); + } + + [Fact] + public async Task Equal_WithNumericValue_Works() + { + var model = new TestModel { Count = 5 }; + + var result = await Validator.For(model) + .RuleFor(x => x.Count) + .Equal(5) + .ToResult(); + + Assert.True(result.IsSuccess); + } + + [Fact] + public async Task Equal_WithCustomMessage_UsesCustomMessage() + { + var model = new TestModel { Status = "Active" }; + + var result = await Validator.For(model) + .RuleFor(x => x.Status) + .Equal("Pending", "Status must be Pending") + .ToResult(); + + Assert.False(result.IsSuccess); + var errors = result.GetMetadataValueOrDefault>(ValidationMetadataKeys.ValidationErrors); + Assert.NotNull(errors); + Assert.Equal("Status must be Pending", errors[0].ErrorMessage); + } + + [Fact] + public async Task NotEqual_WithSameValue_ReturnsFailure() + { + var model = new TestModel { Status = "Deleted" }; + + var result = await Validator.For(model) + .RuleFor(x => x.Status) + .NotEqual("Deleted") + .ToResult(); + + Assert.False(result.IsSuccess); + var errors = result.GetMetadataValueOrDefault>(ValidationMetadataKeys.ValidationErrors); + Assert.NotNull(errors); + Assert.Single(errors); + Assert.Contains("must not equal Deleted", errors[0].ErrorMessage); + } + + [Fact] + public async Task NotEqual_WithDifferentValue_ReturnsSuccess() + { + var model = new TestModel { Status = "Active" }; + + var result = await Validator.For(model) + .RuleFor(x => x.Status) + .NotEqual("Deleted") + .ToResult(); + + Assert.True(result.IsSuccess); + } + + [Fact] + public async Task NotEqual_WithNumericValue_Works() + { + var model = new TestModel { Count = 5 }; + + var result = await Validator.For(model) + .RuleFor(x => x.Count) + .NotEqual(0) + .ToResult(); + + Assert.True(result.IsSuccess); + } + + [Fact] + public async Task NotEqual_WithCustomMessage_UsesCustomMessage() + { + var model = new TestModel { Status = "Deleted" }; + + var result = await Validator.For(model) + .RuleFor(x => x.Status) + .NotEqual("Deleted", "Cannot be deleted") + .ToResult(); + + Assert.False(result.IsSuccess); + var errors = result.GetMetadataValueOrDefault>(ValidationMetadataKeys.ValidationErrors); + Assert.NotNull(errors); + Assert.Equal("Cannot be deleted", errors[0].ErrorMessage); + } + + private class TestModel + { + public string Status { get; set; } = string.Empty; + public int Count { get; set; } + } +} diff --git a/src/ResultR.Validation.Tests/LengthValidationTests.cs b/src/ResultR.Validation.Tests/LengthValidationTests.cs new file mode 100644 index 0000000..dc82094 --- /dev/null +++ b/src/ResultR.Validation.Tests/LengthValidationTests.cs @@ -0,0 +1,160 @@ +namespace ResultR.Validation.Tests; + +public class LengthValidationTests +{ + [Fact] + public async Task MinLength_WithTooShortValue_ReturnsFailure() + { + var model = new TestModel { Name = "ab" }; + + var result = await Validator.For(model) + .RuleFor(x => x.Name) + .MinLength(3) + .ToResult(); + + Assert.False(result.IsSuccess); + var errors = result.GetMetadataValueOrDefault>(ValidationMetadataKeys.ValidationErrors); + Assert.NotNull(errors); + Assert.Single(errors); + Assert.Equal("Name", errors[0].PropertyName); + Assert.Contains("at least 3 characters", errors[0].ErrorMessage); + } + + [Fact] + public async Task MinLength_WithValidValue_ReturnsSuccess() + { + var model = new TestModel { Name = "abc" }; + + var result = await Validator.For(model) + .RuleFor(x => x.Name) + .MinLength(3) + .ToResult(); + + Assert.True(result.IsSuccess); + } + + [Fact] + public async Task MinLength_WithNullValue_ReturnsSuccess() + { + var model = new TestModel { Name = null }; + + var result = await Validator.For(model) + .RuleFor(x => x.Name) + .MinLength(3) + .ToResult(); + + Assert.True(result.IsSuccess); + } + + [Fact] + public async Task MaxLength_WithTooLongValue_ReturnsFailure() + { + var model = new TestModel { Name = "abcdef" }; + + var result = await Validator.For(model) + .RuleFor(x => x.Name) + .MaxLength(5) + .ToResult(); + + Assert.False(result.IsSuccess); + var errors = result.GetMetadataValueOrDefault>(ValidationMetadataKeys.ValidationErrors); + Assert.NotNull(errors); + Assert.Single(errors); + Assert.Contains("5 characters or fewer", errors[0].ErrorMessage); + } + + [Fact] + public async Task MaxLength_WithValidValue_ReturnsSuccess() + { + var model = new TestModel { Name = "abc" }; + + var result = await Validator.For(model) + .RuleFor(x => x.Name) + .MaxLength(5) + .ToResult(); + + Assert.True(result.IsSuccess); + } + + [Fact] + public async Task MaxLength_WithNullValue_ReturnsSuccess() + { + var model = new TestModel { Name = null }; + + var result = await Validator.For(model) + .RuleFor(x => x.Name) + .MaxLength(5) + .ToResult(); + + Assert.True(result.IsSuccess); + } + + [Theory] + [InlineData("ab")] + [InlineData("abcdef")] + public async Task Length_WithOutOfRangeValue_ReturnsFailure(string value) + { + var model = new TestModel { Name = value }; + + var result = await Validator.For(model) + .RuleFor(x => x.Name) + .Length(3, 5) + .ToResult(); + + Assert.False(result.IsSuccess); + var errors = result.GetMetadataValueOrDefault>(ValidationMetadataKeys.ValidationErrors); + Assert.NotNull(errors); + Assert.Single(errors); + Assert.Contains("between 3 and 5 characters", errors[0].ErrorMessage); + } + + [Theory] + [InlineData("abc")] + [InlineData("abcd")] + [InlineData("abcde")] + public async Task Length_WithValidValue_ReturnsSuccess(string value) + { + var model = new TestModel { Name = value }; + + var result = await Validator.For(model) + .RuleFor(x => x.Name) + .Length(3, 5) + .ToResult(); + + Assert.True(result.IsSuccess); + } + + [Fact] + public async Task Length_WithNullValue_ReturnsSuccess() + { + var model = new TestModel { Name = null }; + + var result = await Validator.For(model) + .RuleFor(x => x.Name) + .Length(3, 5) + .ToResult(); + + Assert.True(result.IsSuccess); + } + + [Fact] + public async Task MinLength_WithCustomMessage_UsesCustomMessage() + { + var model = new TestModel { Name = "ab" }; + + var result = await Validator.For(model) + .RuleFor(x => x.Name) + .MinLength(3, "Too short!") + .ToResult(); + + Assert.False(result.IsSuccess); + var errors = result.GetMetadataValueOrDefault>(ValidationMetadataKeys.ValidationErrors); + Assert.NotNull(errors); + Assert.Equal("Too short!", errors[0].ErrorMessage); + } + + private class TestModel + { + public string? Name { get; set; } + } +} diff --git a/src/ResultR.Validation.Tests/MultipleRulesTests.cs b/src/ResultR.Validation.Tests/MultipleRulesTests.cs new file mode 100644 index 0000000..5afbbfb --- /dev/null +++ b/src/ResultR.Validation.Tests/MultipleRulesTests.cs @@ -0,0 +1,137 @@ +namespace ResultR.Validation.Tests; + +public class MultipleRulesTests +{ + [Fact] + public async Task MultipleRules_OnSameProperty_AllValidated() + { + var model = new TestModel { Name = "ab" }; + + var result = await Validator.For(model) + .RuleFor(x => x.Name) + .NotEmpty() + .MinLength(3) + .MaxLength(10) + .ToResult(); + + Assert.False(result.IsSuccess); + var errors = result.GetMetadataValueOrDefault>(ValidationMetadataKeys.ValidationErrors); + Assert.NotNull(errors); + Assert.Single(errors); + Assert.Contains("at least 3 characters", errors[0].ErrorMessage); + } + + [Fact] + public async Task MultipleRules_OnDifferentProperties_AllValidated() + { + var model = new TestModel { Name = "", Age = 5 }; + + var result = await Validator.For(model) + .RuleFor(x => x.Name) + .NotEmpty() + .RuleFor(x => x.Age) + .GreaterThan(10) + .ToResult(); + + Assert.False(result.IsSuccess); + var errors = result.GetMetadataValueOrDefault>(ValidationMetadataKeys.ValidationErrors); + Assert.NotNull(errors); + Assert.Equal(2, errors.Count); + Assert.Contains(errors, e => e.PropertyName == "Name"); + Assert.Contains(errors, e => e.PropertyName == "Age"); + } + + [Fact] + public async Task MultipleRules_AllPass_ReturnsSuccess() + { + var model = new TestModel { Name = "Valid", Age = 25 }; + + var result = await Validator.For(model) + .RuleFor(x => x.Name) + .NotEmpty() + .MinLength(3) + .RuleFor(x => x.Age) + .GreaterThan(10) + .LessThan(100) + .ToResult(); + + Assert.True(result.IsSuccess); + } + + [Fact] + public async Task ChainedRules_WithMultipleFailures_ReturnsAllErrors() + { + var model = new TestModel { Name = "a", Age = 150 }; + + var result = await Validator.For(model) + .RuleFor(x => x.Name) + .MinLength(3) + .MaxLength(10) + .RuleFor(x => x.Age) + .GreaterThan(0) + .LessThan(120) + .ToResult(); + + Assert.False(result.IsSuccess); + var errors = result.GetMetadataValueOrDefault>(ValidationMetadataKeys.ValidationErrors); + Assert.NotNull(errors); + Assert.Equal(2, errors.Count); + } + + [Fact] + public async Task ComplexValidation_WithNestedProperties_Works() + { + var model = new TestModel + { + Name = "Test", + Age = 25, + Email = "test@example.com" + }; + + var result = await Validator.For(model) + .RuleFor(x => x.Name) + .NotEmpty() + .MinLength(2) + .MaxLength(50) + .RuleFor(x => x.Age) + .GreaterThan(0) + .LessThan(150) + .RuleFor(x => x.Email) + .NotEmpty() + .EmailAddress() + .ToResult(); + + Assert.True(result.IsSuccess); + } + + [Fact] + public async Task ValidationBuilder_CanBeReused_ForMultipleValidations() + { + var model1 = new TestModel { Name = "Valid", Age = 25 }; + var model2 = new TestModel { Name = "", Age = 5 }; + + var result1 = await Validator.For(model1) + .RuleFor(x => x.Name) + .NotEmpty() + .RuleFor(x => x.Age) + .GreaterThan(10) + .ToResult(); + + var result2 = await Validator.For(model2) + .RuleFor(x => x.Name) + .NotEmpty() + .RuleFor(x => x.Age) + .GreaterThan(10) + .ToResult(); + + Assert.True(result1.IsSuccess); + Assert.False(result2.IsSuccess); + } + + private class TestModel + { + public string? Name { get; set; } + public int Age { get; set; } + public string? Email { get; set; } + } +} diff --git a/src/ResultR.Validation.Tests/NotEmptyValidationTests.cs b/src/ResultR.Validation.Tests/NotEmptyValidationTests.cs new file mode 100644 index 0000000..9c5c43b --- /dev/null +++ b/src/ResultR.Validation.Tests/NotEmptyValidationTests.cs @@ -0,0 +1,112 @@ +namespace ResultR.Validation.Tests; + +public class NotEmptyValidationTests +{ + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task NotEmpty_String_WithEmptyValue_ReturnsFailure(string? value) + { + var model = new TestModel { Name = value }; + + var result = await Validator.For(model) + .RuleFor(x => x.Name) + .NotEmpty() + .ToResult(); + + Assert.False(result.IsSuccess); + var errors = result.GetMetadataValueOrDefault>(ValidationMetadataKeys.ValidationErrors); + Assert.NotNull(errors); + Assert.Single(errors); + Assert.Equal("Name", errors[0].PropertyName); + Assert.Equal("Name is required.", errors[0].ErrorMessage); + } + + [Fact] + public async Task NotEmpty_String_WithValidValue_ReturnsSuccess() + { + var model = new TestModel { Name = "Valid" }; + + var result = await Validator.For(model) + .RuleFor(x => x.Name) + .NotEmpty() + .ToResult(); + + Assert.True(result.IsSuccess); + } + + [Fact] + public async Task NotEmpty_Collection_WithNullCollection_ReturnsFailure() + { + var model = new TestModelWithNullableItems { Items = null }; + + var result = await Validator.For(model) + .RuleFor(x => x.Items) + .Must(x => x != null && x.Any(), "Items is required.") + .ToResult(); + + Assert.False(result.IsSuccess); + var errors = result.GetMetadataValueOrDefault>(ValidationMetadataKeys.ValidationErrors); + Assert.NotNull(errors); + Assert.Single(errors); + Assert.Equal("Items", errors[0].PropertyName); + } + + [Fact] + public async Task NotEmpty_Collection_WithEmptyCollection_ReturnsFailure() + { + var model = new TestModel { Items = [] }; + + var result = await Validator.For(model) + .RuleFor(x => x.Items) + .NotEmpty() + .ToResult(); + + Assert.False(result.IsSuccess); + var errors = result.GetMetadataValueOrDefault>(ValidationMetadataKeys.ValidationErrors); + Assert.NotNull(errors); + Assert.Single(errors); + Assert.Equal("Items", errors[0].PropertyName); + } + + [Fact] + public async Task NotEmpty_Collection_WithValidCollection_ReturnsSuccess() + { + var model = new TestModel { Items = [1, 2, 3] }; + + var result = await Validator.For(model) + .RuleFor(x => x.Items) + .NotEmpty() + .ToResult(); + + Assert.True(result.IsSuccess); + } + + [Fact] + public async Task NotEmpty_WithCustomMessage_UsesCustomMessage() + { + var model = new TestModel { Name = "" }; + + var result = await Validator.For(model) + .RuleFor(x => x.Name) + .NotEmpty("Custom error") + .ToResult(); + + Assert.False(result.IsSuccess); + var errors = result.GetMetadataValueOrDefault>(ValidationMetadataKeys.ValidationErrors); + Assert.NotNull(errors); + Assert.Equal("Custom error", errors[0].ErrorMessage); + } + + private class TestModel + { + public string? Name { get; set; } + public IEnumerable Items { get; set; } = []; + } + + private class TestModelWithNullableItems + { + public IEnumerable? Items { get; set; } + } +} diff --git a/src/ResultR.Validation.Tests/NotNullValidationTests.cs b/src/ResultR.Validation.Tests/NotNullValidationTests.cs new file mode 100644 index 0000000..fec58c4 --- /dev/null +++ b/src/ResultR.Validation.Tests/NotNullValidationTests.cs @@ -0,0 +1,63 @@ +namespace ResultR.Validation.Tests; + +public class NotNullValidationTests +{ + [Fact] + public async Task NotNull_WithNullValue_ReturnsFailure() + { + var model = new TestModel { Reference = null }; + + var result = await Validator.For(model) + .RuleFor(x => x.Reference) + .NotNull() + .ToResult(); + + Assert.False(result.IsSuccess); + var errors = result.GetMetadataValueOrDefault>(ValidationMetadataKeys.ValidationErrors); + Assert.NotNull(errors); + Assert.Single(errors); + Assert.Equal("Reference", errors[0].PropertyName); + Assert.Equal("Reference is required.", errors[0].ErrorMessage); + } + + [Fact] + public async Task NotNull_WithNonNullValue_ReturnsSuccess() + { + var model = new TestModel { Reference = new TestReference() }; + + var result = await Validator.For(model) + .RuleFor(x => x.Reference) + .NotNull() + .ToResult(); + + Assert.True(result.IsSuccess); + } + + [Fact] + public async Task NotNull_WithCustomMessage_UsesCustomMessage() + { + var model = new TestModel { Reference = null }; + + var result = await Validator.For(model) + .RuleFor(x => x.Reference) + .NotNull("Custom error message") + .ToResult(); + + Assert.False(result.IsSuccess); + var errors = result.GetMetadataValueOrDefault>(ValidationMetadataKeys.ValidationErrors); + Assert.NotNull(errors); + Assert.Single(errors); + Assert.Equal("Custom error message", errors[0].ErrorMessage); + } + + private class TestModel + { + public string? Name { get; set; } + public TestReference? Reference { get; set; } + } + + private class TestReference + { + public string Value { get; set; } = string.Empty; + } +} diff --git a/src/ResultR.Validation.Tests/RegexValidationTests.cs b/src/ResultR.Validation.Tests/RegexValidationTests.cs new file mode 100644 index 0000000..e0ae5c1 --- /dev/null +++ b/src/ResultR.Validation.Tests/RegexValidationTests.cs @@ -0,0 +1,151 @@ +using System.Text.RegularExpressions; + +namespace ResultR.Validation.Tests; + +public class RegexValidationTests +{ + [Fact] + public async Task Matches_WithNonMatchingValue_ReturnsFailure() + { + var model = new TestModel { Code = "ABC" }; + + var result = await Validator.For(model) + .RuleFor(x => x.Code) + .Matches(@"^\d{3}$") + .ToResult(); + + Assert.False(result.IsSuccess); + var errors = result.GetMetadataValueOrDefault>(ValidationMetadataKeys.ValidationErrors); + Assert.NotNull(errors); + Assert.Single(errors); + Assert.Equal("Code", errors[0].PropertyName); + Assert.Contains("invalid", errors[0].ErrorMessage); + } + + [Fact] + public async Task Matches_WithMatchingValue_ReturnsSuccess() + { + var model = new TestModel { Code = "123" }; + + var result = await Validator.For(model) + .RuleFor(x => x.Code) + .Matches(@"^\d{3}$") + .ToResult(); + + Assert.True(result.IsSuccess); + } + + [Fact] + public async Task Matches_WithNullValue_ReturnsSuccess() + { + var model = new TestModel { Code = null }; + + var result = await Validator.For(model) + .RuleFor(x => x.Code) + .Matches(@"^\d{3}$") + .ToResult(); + + Assert.True(result.IsSuccess); + } + + [Fact] + public async Task Matches_WithRegexOptions_AppliesOptions() + { + var model = new TestModel { Code = "ABC" }; + + var result = await Validator.For(model) + .RuleFor(x => x.Code) + .Matches(@"^abc$", options: RegexOptions.IgnoreCase) + .ToResult(); + + Assert.True(result.IsSuccess); + } + + [Fact] + public async Task Matches_WithCustomMessage_UsesCustomMessage() + { + var model = new TestModel { Code = "ABC" }; + + var result = await Validator.For(model) + .RuleFor(x => x.Code) + .Matches(@"^\d{3}$", "Must be 3 digits") + .ToResult(); + + Assert.False(result.IsSuccess); + var errors = result.GetMetadataValueOrDefault>(ValidationMetadataKeys.ValidationErrors); + Assert.NotNull(errors); + Assert.Equal("Must be 3 digits", errors[0].ErrorMessage); + } + + [Theory] + [InlineData("test@example.com")] + [InlineData("user.name@domain.co.uk")] + [InlineData("test+tag@example.com")] + public async Task EmailAddress_WithValidEmail_ReturnsSuccess(string email) + { + var model = new TestModel { Email = email }; + + var result = await Validator.For(model) + .RuleFor(x => x.Email) + .EmailAddress() + .ToResult(); + + Assert.True(result.IsSuccess); + } + + [Theory] + [InlineData("invalid")] + [InlineData("@example.com")] + [InlineData("test@")] + [InlineData("test @example.com")] + public async Task EmailAddress_WithInvalidEmail_ReturnsFailure(string email) + { + var model = new TestModel { Email = email }; + + var result = await Validator.For(model) + .RuleFor(x => x.Email) + .EmailAddress() + .ToResult(); + + Assert.False(result.IsSuccess); + var errors = result.GetMetadataValueOrDefault>(ValidationMetadataKeys.ValidationErrors); + Assert.NotNull(errors); + Assert.Single(errors); + Assert.Contains("valid email", errors[0].ErrorMessage); + } + + [Fact] + public async Task EmailAddress_WithNullValue_ReturnsSuccess() + { + var model = new TestModel { Email = null }; + + var result = await Validator.For(model) + .RuleFor(x => x.Email) + .EmailAddress() + .ToResult(); + + Assert.True(result.IsSuccess); + } + + [Fact] + public async Task EmailAddress_WithCustomMessage_UsesCustomMessage() + { + var model = new TestModel { Email = "invalid" }; + + var result = await Validator.For(model) + .RuleFor(x => x.Email) + .EmailAddress("Invalid email format") + .ToResult(); + + Assert.False(result.IsSuccess); + var errors = result.GetMetadataValueOrDefault>(ValidationMetadataKeys.ValidationErrors); + Assert.NotNull(errors); + Assert.Equal("Invalid email format", errors[0].ErrorMessage); + } + + private class TestModel + { + public string? Code { get; set; } + public string? Email { get; set; } + } +} diff --git a/src/ResultR.Validation.Tests/ResultR.Validation.Tests.csproj b/src/ResultR.Validation.Tests/ResultR.Validation.Tests.csproj new file mode 100644 index 0000000..e47b009 --- /dev/null +++ b/src/ResultR.Validation.Tests/ResultR.Validation.Tests.csproj @@ -0,0 +1,28 @@ +ο»Ώ + + + net10.0 + enable + enable + false + true + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/ResultR.Validation.Tests/ValidatorTests.cs b/src/ResultR.Validation.Tests/ValidatorTests.cs new file mode 100644 index 0000000..a70d0ee --- /dev/null +++ b/src/ResultR.Validation.Tests/ValidatorTests.cs @@ -0,0 +1,30 @@ +namespace ResultR.Validation.Tests; + +public class ValidatorTests +{ + [Fact] + public void For_WithValidInstance_ReturnsValidationBuilder() + { + var instance = new TestModel { Name = "Test" }; + + var builder = Validator.For(instance); + + Assert.NotNull(builder); + Assert.Equal(instance, builder.Instance); + } + + [Fact] + public void For_WithNullInstance_ThrowsArgumentNullException() + { + TestModel? instance = null; + + var exception = Assert.Throws(() => Validator.For(instance!)); + + Assert.Equal("instance", exception.ParamName); + } + + private class TestModel + { + public string? Name { get; set; } + } +} diff --git a/src/ResultR.Validation/ExpressionUtilities.cs b/src/ResultR.Validation/ExpressionUtilities.cs new file mode 100644 index 0000000..80ea131 --- /dev/null +++ b/src/ResultR.Validation/ExpressionUtilities.cs @@ -0,0 +1,23 @@ +using System.Linq.Expressions; + +namespace ResultR.Validation; + +internal static class ExpressionUtilities +{ + public static string GetMemberName(Expression> expression) + { + var memberExpression = expression.Body switch + { + MemberExpression member => member, + UnaryExpression { Operand: MemberExpression inner } => inner, + _ => null + }; + + if (memberExpression is null) + { + throw new ArgumentException("Expression must target a property or field", nameof(expression)); + } + + return memberExpression.Member.Name; + } +} diff --git a/src/ResultR.Validation/README.md b/src/ResultR.Validation/README.md new file mode 100644 index 0000000..b3e7e64 --- /dev/null +++ b/src/ResultR.Validation/README.md @@ -0,0 +1,146 @@ +# ResultR.Validation + +Lightweight inline validation framework for ResultR. Define validation rules directly in your `ValidateAsync()` method using a fluent API, with seamless integration into ResultR's pipeline hooks. + +## Installation + +```bash +dotnet add package ResultR.Validation +``` + +## Quick Start + +```csharp +using ResultR; +using ResultR.Validation; + +public record CreateUserRequest(string Email, string Name, int Age) : IRequest; + +public class CreateUserHandler : IRequestHandler +{ + public ValueTask ValidateAsync(CreateUserRequest request) + { + return Validator.For(request) + .RuleFor(x => x.Email) + .NotEmpty("Email is required") + .EmailAddress("Invalid email format") + .RuleFor(x => x.Name) + .NotEmpty("Name is required") + .MinLength(2, "Name must be at least 2 characters") + .MaxLength(100, "Name cannot exceed 100 characters") + .RuleFor(x => x.Age) + .GreaterThan(0, "Age must be positive") + .LessThanOrEqualTo(150, "Age must be realistic") + .ToResult(); + } + + public async ValueTask> HandleAsync(CreateUserRequest request, CancellationToken ct) + { + var user = new User(request.Email, request.Name, request.Age); + // Save user... + return Result.Success(user); + } +} +``` + +## Features + +- **Zero ceremony** - No external validator classes, no DI registration for validators +- **Inline validation** - Define rules directly in `ValidateAsync()` using a fluent API +- **Seamless integration** - Works with ResultR's `IRequestHandler` and `IRequestHandler` +- **Automatic result conversion** - Returns `Result.Success()` or `Result.Failure()` with aggregated errors +- **Comprehensive built-in rules** - String, numeric, collection, and custom validations + +## Built-in Validation Rules + +### String Validations +- `NotEmpty()` - Ensures string is not null, empty, or whitespace +- `NotNull()` - Ensures value is not null +- `MinLength(int)` - Minimum string length +- `MaxLength(int)` - Maximum string length +- `Length(int, int)` - String length range +- `Matches(string pattern)` - Regex pattern matching +- `EmailAddress()` - Valid email format + +### Numeric Validations +- `GreaterThan(T)` - Value must be greater than comparison +- `GreaterThanOrEqualTo(T)` - Value must be greater than or equal to comparison +- `LessThan(T)` - Value must be less than comparison +- `LessThanOrEqualTo(T)` - Value must be less than or equal to comparison +- `Between(T, T)` - Value must be within range (inclusive) + +### Collection Validations +- `NotEmpty()` - Collection must contain at least one element + +### General Validations +- `NotNull()` - Value must not be null +- `Equal(T)` - Value must equal comparison +- `NotEqual(T)` - Value must not equal comparison +- `Must(Func)` - Custom predicate validation + +## Custom Validation Rules + +Use the `Must()` method for custom validation logic: + +```csharp +public ValueTask ValidateAsync(CreateUserRequest request) +{ + return Validator.For(request) + .RuleFor(x => x.Email) + .NotEmpty("Email is required") + .Must(email => email.EndsWith("@company.com"), "Must use company email") + .RuleFor(x => x.Name) + .Must(name => !name.Contains("admin", StringComparison.OrdinalIgnoreCase), + "Name cannot contain 'admin'") + .ToResult(); +} +``` + +## Accessing Validation Errors + +When validation fails, errors are stored in the `Result` metadata: + +```csharp +var result = await mediator.SendAsync(request); + +if (result.IsFailure) +{ + var errors = result.GetMetadataValueOrDefault>(ValidationMetadataKeys.ValidationErrors); + + if (errors is not null) + { + foreach (var error in errors) + { + Console.WriteLine($"{error.PropertyName}: {error.ErrorMessage}"); + } + } +} + +// Example output: +// Email: Email is required +// Name: Name must be at least 2 characters +// Age: Age must be positive +``` + +## How It Works + +1. `Validator.For(request)` creates a `ValidationBuilder` for the request +2. Each `RuleFor()` call selects a property and returns a `RuleBuilder` +3. Validation methods (e.g., `NotEmpty()`, `MinLength()`) add rules to an internal list +4. `ToResult()` executes all rules and returns: + - `Result.Success()` if all rules pass + - `Result.Failure("Validation failed")` with errors in metadata if any rule fails + +## Why ResultR.Validation? + +Unlike FluentValidation which requires separate validator classes and DI registration, ResultR.Validation lets you define validation rules **inline** within your handler's `ValidateAsync()` method. This reduces ceremony and keeps validation logic close to your business logic. + +## License + +ISC License - see the [LICENSE](https://github.com/AlanBarber/ResultR/blob/main/LICENSE) file for details. + +## Links + +- [GitHub Repository](https://github.com/AlanBarber/ResultR) +- [ResultR Core Package](https://www.nuget.org/packages/ResultR) +- [Documentation](https://github.com/AlanBarber/ResultR/wiki) diff --git a/src/ResultR.Validation/ResultR.Validation.csproj b/src/ResultR.Validation/ResultR.Validation.csproj new file mode 100644 index 0000000..ad59716 --- /dev/null +++ b/src/ResultR.Validation/ResultR.Validation.csproj @@ -0,0 +1,36 @@ +ο»Ώ + + + net10.0 + 14.0 + enable + enable + true + + + ResultR.Validation + Alan Barber + Lightweight inline validation framework for ResultR. Define validation rules directly in ValidateAsync() using a fluent API, with seamless integration into ResultR's pipeline hooks. + resultr;validation;mediator;cqrs;result;fluent;inline + ISC + false + https://github.com/AlanBarber/ResultR + https://github.com/AlanBarber/ResultR + git + README.md + + + true + true + snupkg + + + + + + + + + + + diff --git a/src/ResultR.Validation/RuleBuilder.cs b/src/ResultR.Validation/RuleBuilder.cs new file mode 100644 index 0000000..ac6f3c5 --- /dev/null +++ b/src/ResultR.Validation/RuleBuilder.cs @@ -0,0 +1,41 @@ +using System.Linq.Expressions; +using ResultR; + +namespace ResultR.Validation; + +/// +/// Fluent builder for configuring validation rules on a single property. +/// +public sealed class RuleBuilder +{ + private readonly ValidationBuilder _builder; + private readonly Func _accessor; + + internal RuleBuilder(ValidationBuilder builder, Expression> expression) + { + _builder = builder; + _accessor = expression.Compile(); + PropertyName = ExpressionUtilities.GetMemberName(expression); + } + + internal string PropertyName { get; } + + internal RuleBuilder AddError(string message) + { + _builder.AddError(PropertyName, message); + return this; + } + + internal TProperty GetValue() => _accessor(_builder.Instance); + + /// + /// Allows chaining validation for another property without breaking the fluent pipeline. + /// + public RuleBuilder RuleFor(Expression> expression) + => _builder.RuleFor(expression); + + /// + /// Completes validation and converts accumulated errors into a . + /// + public ValueTask ToResult() => _builder.ToResult(); +} diff --git a/src/ResultR.Validation/RuleBuilderExtensions.cs b/src/ResultR.Validation/RuleBuilderExtensions.cs new file mode 100644 index 0000000..3298302 --- /dev/null +++ b/src/ResultR.Validation/RuleBuilderExtensions.cs @@ -0,0 +1,426 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + +namespace ResultR.Validation; + +/// +/// Extension methods providing common validation rules for . +/// +public static class RuleBuilderExtensions +{ + private const string DefaultRequiredMessage = "{0} is required."; + private const string DefaultInvalidMessage = "{0} is invalid."; + + private static string FormatRequired(string? message, string propertyName) + => message ?? string.Format(DefaultRequiredMessage, propertyName); + + private static bool IsNull(TProperty value) + { + object? boxed = value; + return boxed is null; + } + + /// + /// Validates that the property value is not null. + /// + /// The type being validated. + /// The property type. + /// The rule builder. + /// Optional custom error message. + /// The rule builder for method chaining. + public static RuleBuilder NotNull( + this RuleBuilder rule, + string? message = null) + { + if (rule.GetValue() is null) + { + rule.AddError(FormatRequired(message, rule.PropertyName)); + } + + return rule; + } + + /// + /// Validates that the string property is not null, empty, or whitespace. + /// + /// The type being validated. + /// The rule builder. + /// Optional custom error message. + /// The rule builder for method chaining. + public static RuleBuilder NotEmpty( + this RuleBuilder rule, + string? message = null) + { + if (string.IsNullOrWhiteSpace(rule.GetValue())) + { + rule.AddError(FormatRequired(message, rule.PropertyName)); + } + + return rule; + } + + /// + /// Validates that the collection property is not null or empty. + /// + /// The type being validated. + /// The collection element type. + /// The rule builder. + /// Optional custom error message. + /// The rule builder for method chaining. + public static RuleBuilder> NotEmpty( + this RuleBuilder> rule, + string? message = null) + { + var value = rule.GetValue(); + if (value is null || !value.Any()) + { + rule.AddError(FormatRequired(message, rule.PropertyName)); + } + + return rule; + } + + /// + /// Validates that the string property has a minimum length. + /// + /// The type being validated. + /// The rule builder. + /// The minimum allowed length. + /// Optional custom error message. + /// The rule builder for method chaining. + public static RuleBuilder MinLength( + this RuleBuilder rule, + int minLength, + string? message = null) + { + var value = rule.GetValue(); + if (value is not null && value.Length < minLength) + { + rule.AddError(message ?? $"{rule.PropertyName} must be at least {minLength} characters."); + } + + return rule; + } + + /// + /// Validates that the string property does not exceed a maximum length. + /// + /// The type being validated. + /// The rule builder. + /// The maximum allowed length. + /// Optional custom error message. + /// The rule builder for method chaining. + public static RuleBuilder MaxLength( + this RuleBuilder rule, + int maxLength, + string? message = null) + { + var value = rule.GetValue(); + if (value is not null && value.Length > maxLength) + { + rule.AddError(message ?? $"{rule.PropertyName} must be {maxLength} characters or fewer."); + } + + return rule; + } + + /// + /// Validates that the string property length is within a specified range. + /// + /// The type being validated. + /// The rule builder. + /// The minimum allowed length. + /// The maximum allowed length. + /// Optional custom error message. + /// The rule builder for method chaining. + public static RuleBuilder Length( + this RuleBuilder rule, + int minLength, + int maxLength, + string? message = null) + { + var value = rule.GetValue(); + if (value is null) + { + return rule; + } + + if (value.Length < minLength || value.Length > maxLength) + { + rule.AddError(message ?? $"{rule.PropertyName} must be between {minLength} and {maxLength} characters."); + } + + return rule; + } + + /// + /// Validates that the string property matches a regular expression pattern. + /// + /// The type being validated. + /// The rule builder. + /// The regular expression pattern to match. + /// Optional custom error message. + /// Regular expression options. + /// The rule builder for method chaining. + public static RuleBuilder Matches( + this RuleBuilder rule, + string pattern, + string? message = null, + RegexOptions options = RegexOptions.None) + { + var value = rule.GetValue(); + if (value is null) + { + return rule; + } + + if (!Regex.IsMatch(value, pattern, options)) + { + rule.AddError(message ?? string.Format(DefaultInvalidMessage, rule.PropertyName)); + } + + return rule; + } + + /// + /// Validates that the string property is a valid email address format. + /// + /// The type being validated. + /// The rule builder. + /// Optional custom error message. + /// The rule builder for method chaining. + public static RuleBuilder EmailAddress( + this RuleBuilder rule, + string? message = null) + { + var value = rule.GetValue(); + if (value is null) + { + return rule; + } + + const string pattern = @"^[^@\s]+@[^@\s]+\.[^@\s]+$"; + if (!Regex.IsMatch(value, pattern, RegexOptions.IgnoreCase)) + { + rule.AddError(message ?? $"{rule.PropertyName} must be a valid email address."); + } + + return rule; + } + + /// + /// Validates that the property value is greater than the specified comparison value. + /// + /// The type being validated. + /// The property type. + /// The rule builder. + /// The value to compare against. + /// Optional custom error message. + /// The rule builder for method chaining. + public static RuleBuilder GreaterThan( + this RuleBuilder rule, + TProperty comparisonValue, + string? message = null) + where TProperty : IComparable + { + var value = rule.GetValue(); + if (IsNull(value)) + { + return rule; + } + + if (value.CompareTo(comparisonValue) <= 0) + { + rule.AddError(message ?? $"{rule.PropertyName} must be greater than {comparisonValue}."); + } + + return rule; + } + + /// + /// Validates that the property value is greater than or equal to the specified comparison value. + /// + /// The type being validated. + /// The property type. + /// The rule builder. + /// The value to compare against. + /// Optional custom error message. + /// The rule builder for method chaining. + public static RuleBuilder GreaterThanOrEqualTo( + this RuleBuilder rule, + TProperty comparisonValue, + string? message = null) + where TProperty : IComparable + { + var value = rule.GetValue(); + if (IsNull(value)) + { + return rule; + } + + if (value.CompareTo(comparisonValue) < 0) + { + rule.AddError(message ?? $"{rule.PropertyName} must be greater than or equal to {comparisonValue}."); + } + + return rule; + } + + /// + /// Validates that the property value is less than the specified comparison value. + /// + /// The type being validated. + /// The property type. + /// The rule builder. + /// The value to compare against. + /// Optional custom error message. + /// The rule builder for method chaining. + public static RuleBuilder LessThan( + this RuleBuilder rule, + TProperty comparisonValue, + string? message = null) + where TProperty : IComparable + { + var value = rule.GetValue(); + if (IsNull(value)) + { + return rule; + } + + if (value.CompareTo(comparisonValue) >= 0) + { + rule.AddError(message ?? $"{rule.PropertyName} must be less than {comparisonValue}."); + } + + return rule; + } + + /// + /// Validates that the property value is less than or equal to the specified comparison value. + /// + /// The type being validated. + /// The property type. + /// The rule builder. + /// The value to compare against. + /// Optional custom error message. + /// The rule builder for method chaining. + public static RuleBuilder LessThanOrEqualTo( + this RuleBuilder rule, + TProperty comparisonValue, + string? message = null) + where TProperty : IComparable + { + var value = rule.GetValue(); + if (IsNull(value)) + { + return rule; + } + + if (value.CompareTo(comparisonValue) > 0) + { + rule.AddError(message ?? $"{rule.PropertyName} must be less than or equal to {comparisonValue}."); + } + + return rule; + } + + /// + /// Validates that the property value is within the specified range (inclusive). + /// + /// The type being validated. + /// The property type. + /// The rule builder. + /// The minimum allowed value. + /// The maximum allowed value. + /// Optional custom error message. + /// The rule builder for method chaining. + public static RuleBuilder Between( + this RuleBuilder rule, + TProperty minValue, + TProperty maxValue, + string? message = null) + where TProperty : IComparable + { + var value = rule.GetValue(); + if (IsNull(value)) + { + return rule; + } + + if (value.CompareTo(minValue) < 0 || value.CompareTo(maxValue) > 0) + { + rule.AddError(message ?? $"{rule.PropertyName} must be between {minValue} and {maxValue}."); + } + + return rule; + } + + /// + /// Validates that the property value equals the specified comparison value. + /// + /// The type being validated. + /// The property type. + /// The rule builder. + /// The value to compare against. + /// Optional custom error message. + /// The rule builder for method chaining. + public static RuleBuilder Equal( + this RuleBuilder rule, + TProperty comparisonValue, + string? message = null) + where TProperty : IEquatable + { + if (!EqualityComparer.Default.Equals(rule.GetValue(), comparisonValue)) + { + rule.AddError(message ?? $"{rule.PropertyName} must equal {comparisonValue}."); + } + + return rule; + } + + /// + /// Validates that the property value does not equal the specified disallowed value. + /// + /// The type being validated. + /// The property type. + /// The rule builder. + /// The value that is not allowed. + /// Optional custom error message. + /// The rule builder for method chaining. + public static RuleBuilder NotEqual( + this RuleBuilder rule, + TProperty disallowedValue, + string? message = null) + where TProperty : IEquatable + { + if (EqualityComparer.Default.Equals(rule.GetValue(), disallowedValue)) + { + rule.AddError(message ?? $"{rule.PropertyName} must not equal {disallowedValue}."); + } + + return rule; + } + + /// + /// Validates that the property value satisfies a custom predicate. + /// + /// The type being validated. + /// The property type. + /// The rule builder. + /// The predicate function that must return true for the value to be valid. + /// The error message to use if validation fails. + /// The rule builder for method chaining. + public static RuleBuilder Must( + this RuleBuilder rule, + Func predicate, + string message) + { + if (!predicate(rule.GetValue())) + { + rule.AddError(message); + } + + return rule; + } +} diff --git a/src/ResultR.Validation/ValidationBuilder.cs b/src/ResultR.Validation/ValidationBuilder.cs new file mode 100644 index 0000000..0672ff3 --- /dev/null +++ b/src/ResultR.Validation/ValidationBuilder.cs @@ -0,0 +1,50 @@ +using System.Linq.Expressions; +using ResultR; + +namespace ResultR.Validation; + +/// +/// Fluent builder that aggregates validation rules for a given instance. +/// +public sealed class ValidationBuilder +{ + private readonly List _errors = []; + + internal ValidationBuilder(T instance) => Instance = instance; + + /// + /// The instance being validated. + /// + public T Instance { get; } + + /// + /// Begins configuring validation rules for a specific property. + /// + /// The property type. + /// Expression selecting the property. + public RuleBuilder RuleFor(Expression> expression) + => new(this, expression); + + /// + /// Finalizes validation and converts accumulated errors into a . + /// + public ValueTask ToResult() + { + if (_errors.Count == 0) + { + return new(ResultR.Result.Success()); + } + + var failure = ResultR.Result + .Failure("Validation failed") + .WithMetadata(ValidationMetadataKeys.ValidationErrors, _errors.ToList()); + + return new(failure); + } + + internal ValidationBuilder AddError(string propertyName, string errorMessage) + { + _errors.Add(new ValidationError(propertyName, errorMessage)); + return this; + } +} diff --git a/src/ResultR.Validation/ValidationError.cs b/src/ResultR.Validation/ValidationError.cs new file mode 100644 index 0000000..3c58230 --- /dev/null +++ b/src/ResultR.Validation/ValidationError.cs @@ -0,0 +1,8 @@ +namespace ResultR.Validation; + +/// +/// Represents a single validation error for a specific property. +/// +/// Name of the property that failed validation. +/// The associated validation message. +public sealed record ValidationError(string PropertyName, string ErrorMessage); diff --git a/src/ResultR.Validation/ValidationMetadataKeys.cs b/src/ResultR.Validation/ValidationMetadataKeys.cs new file mode 100644 index 0000000..c91451c --- /dev/null +++ b/src/ResultR.Validation/ValidationMetadataKeys.cs @@ -0,0 +1,12 @@ +namespace ResultR.Validation; + +/// +/// Metadata keys used for storing validation-related data in Result objects. +/// +public static class ValidationMetadataKeys +{ + /// + /// Metadata key for storing a list of validation errors in a failed Result. + /// + public const string ValidationErrors = "ValidationErrors"; +} diff --git a/src/ResultR.Validation/Validator.cs b/src/ResultR.Validation/Validator.cs new file mode 100644 index 0000000..fa8de01 --- /dev/null +++ b/src/ResultR.Validation/Validator.cs @@ -0,0 +1,23 @@ +ο»Ώnamespace ResultR.Validation; + +/// +/// Entry point for creating inline validators against a specific request instance. +/// +public static class Validator +{ + /// + /// Creates a for the provided instance. + /// + /// The type of the instance being validated. + /// The instance to validate. + /// A validation builder that can be used to define rules. + public static ValidationBuilder For(T instance) + { + if (instance is null) + { + throw new ArgumentNullException(nameof(instance)); + } + + return new ValidationBuilder(instance); + } +} diff --git a/src/ResultR.slnx b/src/ResultR.slnx index 73ede48..d8d2119 100644 --- a/src/ResultR.slnx +++ b/src/ResultR.slnx @@ -2,4 +2,6 @@ + + diff --git a/src/ResultR/ResultR.csproj b/src/ResultR/ResultR.csproj index 630cff3..d86b932 100644 --- a/src/ResultR/ResultR.csproj +++ b/src/ResultR/ResultR.csproj @@ -7,12 +7,13 @@ enable true - + ResultR - A lightweight request/response dispatcher for .NET applications. It routes requests to handlers and wraps all responses in a Result type for consistent success/failure handling. Alan Barber + A lightweight request/response dispatcher for .NET applications. It routes requests to handlers and wraps all responses in a Result type for consistent success/failure handling. dispatcher;mediator;cqrs;result;pipeline;handler ISC + false https://github.com/AlanBarber/ResultR https://github.com/AlanBarber/ResultR git From e34beeb58eff3cc4de24eb2cb513aa063df77d41 Mon Sep 17 00:00:00 2001 From: Alan Barber Date: Sun, 28 Dec 2025 15:31:43 -0500 Subject: [PATCH 2/2] updating readme.md --- src/ResultR.Validation/README.md | 42 ++++++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/src/ResultR.Validation/README.md b/src/ResultR.Validation/README.md index b3e7e64..fa164c6 100644 --- a/src/ResultR.Validation/README.md +++ b/src/ResultR.Validation/README.md @@ -1,14 +1,23 @@ -# ResultR.Validation +# 🎯 ResultR.Validation + +[![GitHub Release](https://img.shields.io/github/v/release/AlanBarber/ResultR)](https://github.com/AlanBarber/ResultR/releases) +[![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/AlanBarber/ResultR/build.yml)](https://github.com/AlanBarber/ResultR/actions/workflows/build.yml) +[![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/AlanBarber/ResultR/total?label=github%20downloads)](https://github.com/AlanBarber/ResultR/releases) +[![NuGet Version](https://img.shields.io/nuget/v/ResultR.Validation)](https://www.nuget.org/packages/ResultR.Validation) +[![NuGet Downloads](https://img.shields.io/nuget/dt/ResultR.Validation?label=nuget%20downloads)](https://www.nuget.org/packages/ResultR.Validation) +[![GitHub License](https://img.shields.io/github/license/alanbarber/ResultR)](https://github.com/AlanBarber/ResultR/blob/main/LICENSE) + +## πŸ“– Overview Lightweight inline validation framework for ResultR. Define validation rules directly in your `ValidateAsync()` method using a fluent API, with seamless integration into ResultR's pipeline hooks. -## Installation +## πŸ“₯ Installation ```bash dotnet add package ResultR.Validation ``` -## Quick Start +## πŸš€ Quick Start ```csharp using ResultR; @@ -131,16 +140,33 @@ if (result.IsFailure) - `Result.Success()` if all rules pass - `Result.Failure("Validation failed")` with errors in metadata if any rule fails -## Why ResultR.Validation? +## ❓ Why ResultR.Validation? Unlike FluentValidation which requires separate validator classes and DI registration, ResultR.Validation lets you define validation rules **inline** within your handler's `ValidateAsync()` method. This reduces ceremony and keeps validation logic close to your business logic. -## License - -ISC License - see the [LICENSE](https://github.com/AlanBarber/ResultR/blob/main/LICENSE) file for details. - ## Links - [GitHub Repository](https://github.com/AlanBarber/ResultR) - [ResultR Core Package](https://www.nuget.org/packages/ResultR) - [Documentation](https://github.com/AlanBarber/ResultR/wiki) + +## πŸ“‹ Requirements + +- .NET 10.0 or later +- C# 14.0 or later + +## 🀝 Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +## πŸ“„ License + +ISC License - see the [LICENSE](https://github.com/AlanBarber/ResultR/blob/main/LICENSE) file for details. + +## πŸ’¬ Support + +- **Issues**: [GitHub Issues](https://github.com/AlanBarber/ResultR/issues) + +--- + +Built with ❀️ for the C# / DotNet community.