Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 27 additions & 14 deletions .claude/skills/minimal-api/references/patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -363,24 +363,37 @@ public static class ProductsPermissions

## Validation Classes

Use FluentValidation `AbstractValidator<T>`. The framework provides a `ToValidationErrors()` extension that converts FluentValidation's `ValidationResult` to the `Dictionary<string, string[]>` shape consumed by `ValidationException` and the RFC 7807 response writer.

```csharp
public static class CreateRequestValidator
public sealed class CreateRequestValidator : AbstractValidator<CreateProductRequest>
{
public static ValidationResult Validate(CreateProductRequest request) =>
new ValidationBuilder()
.AddErrorIf(
string.IsNullOrWhiteSpace(request.Name),
"Name",
"Product name is required."
)
.AddErrorIf(request.Price <= 0, "Price", "Price must be greater than zero.")
.Build();
public CreateRequestValidator()
{
RuleFor(x => x.Name).NotEmpty().WithMessage("Product name is required.");
RuleFor(x => x.Price).GreaterThan(0).WithMessage("Price must be greater than zero.");
}
}
```

Usage in endpoint:
Register once per module in `ConfigureServices`:

```csharp
var validation = CreateRequestValidator.Validate(request);
if (!validation.IsValid)
throw new ValidationException(validation.Errors);
services.AddValidatorsFromAssemblyContaining<ThisModule>();
```

Usage in endpoint (async lambda, inject `IValidator<TRequest>`):

```csharp
async (
CreateProductRequest request,
IValidator<CreateProductRequest> validator,
IProductContracts contracts
) =>
{
var validation = await validator.ValidateAsync(request);
if (!validation.IsValid)
throw new Core.Exceptions.ValidationException(validation.ToValidationErrors());
// ...
}
```
27 changes: 21 additions & 6 deletions .claude/skills/simplemodule/references/endpoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,14 +150,29 @@ var body = await JsonSerializer.DeserializeAsync<MyType>(context.Request.Body);

## Validation

Use FluentValidation `AbstractValidator<T>`. Register via `services.AddValidatorsFromAssemblyContaining<YourModule>()` in `ConfigureServices`. Inject `IValidator<TRequest>` into the endpoint handler.

```csharp
public static class CreateRequestValidator
public sealed class CreateRequestValidator : AbstractValidator<CreateProductRequest>
{
public CreateRequestValidator()
{
RuleFor(x => x.Name).NotEmpty().WithMessage("Product name is required.");
RuleFor(x => x.Price).GreaterThan(0).WithMessage("Price must be greater than zero.");
}
}

// In the endpoint lambda:
async (
CreateProductRequest request,
IValidator<CreateProductRequest> validator,
IProductContracts products
) =>
{
public static ValidationResult Validate(CreateProductRequest request) =>
new ValidationBuilder()
.AddErrorIf(string.IsNullOrWhiteSpace(request.Name), "Name", "Product name is required.")
.AddErrorIf(request.Price <= 0, "Price", "Price must be greater than zero.")
.Build();
var validation = await validator.ValidateAsync(request);
if (!validation.IsValid)
throw new Core.Exceptions.ValidationException(validation.ToValidationErrors());
// ...
}
```

Expand Down
3 changes: 3 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@
<PackageVersion Include="Cronos" Version="0.8.4" />
<!-- Caching -->
<PackageVersion Include="ZiggyCreatures.FusionCache" Version="2.6.0" />
<!-- Validation -->
<PackageVersion Include="FluentValidation" Version="12.1.1" />
<PackageVersion Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
<!-- Email -->
<PackageVersion Include="MailKit" Version="4.15.1" />
<!-- CLI -->
Expand Down
18 changes: 5 additions & 13 deletions cli/SimpleModule.Cli/Templates/FeatureTemplates.cs
Original file line number Diff line number Diff line change
Expand Up @@ -277,11 +277,7 @@ string singularName
var newBody = new List<string>
{
resultLines[braceStart], // opening brace
" var errors = new Dictionary<string, string[]>();",
"",
" // TODO: add validation rules",
"",
" return errors.Count > 0 ? ValidationResult.WithErrors(errors) : ValidationResult.Success;",
" // TODO: add validation rules via RuleFor(x => x.Prop)...",
};

// Keep the closing brace
Expand Down Expand Up @@ -369,20 +365,16 @@ private static string FallbackValidator(
string singularName
) =>
$$"""
using SimpleModule.Core.Validation;
using FluentValidation;
using SimpleModule.{{moduleName}}.Contracts;

namespace SimpleModule.{{moduleName}}.Endpoints.{{moduleName}};

public static class {{featureName}}RequestValidator
public sealed class {{featureName}}RequestValidator : AbstractValidator<{{singularName}}>
{
public static ValidationResult Validate({{singularName}} request)
public {{featureName}}RequestValidator()
{
var errors = new Dictionary<string, string[]>();

// TODO: add validation rules

return errors.Count > 0 ? ValidationResult.WithErrors(errors) : ValidationResult.Success;
// TODO: add validation rules via RuleFor(x => x.Prop)...
}
}
""";
Expand Down
2 changes: 2 additions & 0 deletions framework/SimpleModule.Core/SimpleModule.Core.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,7 @@
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="NetEscapades.EnumGenerators" PrivateAssets="all" />
<PackageReference Include="ZiggyCreatures.FusionCache" />
<PackageReference Include="FluentValidation" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" />
</ItemGroup>
</Project>
140 changes: 0 additions & 140 deletions framework/SimpleModule.Core/Validation/ValidationBuilder.cs

This file was deleted.

9 changes: 0 additions & 9 deletions framework/SimpleModule.Core/Validation/ValidationResult.cs

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using FluentValidation.Results;

namespace SimpleModule.Core.Validation;

/// <summary>
/// Bridges FluentValidation's <see cref="ValidationResult"/> to the
/// <see cref="Dictionary{TKey,TValue}"/> shape consumed by
/// <see cref="SimpleModule.Core.Exceptions.ValidationException"/> and
/// the RFC 7807 <c>errors</c> extension written by <c>GlobalExceptionHandler</c>.
/// </summary>
public static class ValidationResultExtensions
{
/// <summary>
/// Groups validation failures by property name, flattening their error
/// messages into a <c>string[]</c> per field.
/// </summary>
public static Dictionary<string, string[]> ToValidationErrors(this ValidationResult result)
{
ArgumentNullException.ThrowIfNull(result);
return result
.Errors.GroupBy(e => e.PropertyName, StringComparer.Ordinal)
.ToDictionary(
g => g.Key,
g => g.Select(e => e.ErrorMessage).ToArray(),
StringComparer.Ordinal
);
}
}
2 changes: 2 additions & 0 deletions modules/Email/src/SimpleModule.Email/EmailModule.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using FluentValidation;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
Expand All @@ -23,6 +24,7 @@ public class EmailModule : IModule, IModuleServices
public void ConfigureServices(IServiceCollection services, IConfiguration configuration)
{
services.AddModuleDbContext<EmailDbContext>(configuration, EmailConstants.ModuleName);
services.AddValidatorsFromAssemblyContaining<EmailModule>();
var emailSection = configuration.GetSection("Email");
services.Configure<EmailModuleOptions>(emailSection);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
using FluentValidation;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using SimpleModule.Core;
using SimpleModule.Core.Authorization;
using SimpleModule.Core.Validation;
using SimpleModule.Email.Contracts;
using SimpleModule.Email.Validators;

namespace SimpleModule.Email.Endpoints.Messages;

Expand All @@ -16,11 +17,17 @@ public class SendEmailEndpoint : IEndpoint
public void Map(IEndpointRouteBuilder app) =>
app.MapPost(
Route,
async (SendEmailRequest request, IEmailContracts emailContracts) =>
async (
SendEmailRequest request,
IValidator<SendEmailRequest> validator,
IEmailContracts emailContracts
) =>
{
var validation = SendEmailRequestValidator.Validate(request);
var validation = await validator.ValidateAsync(request);
if (!validation.IsValid)
throw new Core.Exceptions.ValidationException(validation.Errors);
throw new Core.Exceptions.ValidationException(
validation.ToValidationErrors()
);

var message = await emailContracts.SendEmailAsync(request);
return TypedResults.Ok(message);
Expand Down
Loading
Loading